Stalwart TOTP + Aliases
Notes › _DIFFUSION › Stalwart
---
title:
name:
level:
center:
exclude:
style:
listType:
omit:
levels:
min:
max:
---
# Table of Contents
- Stalwart Mail Aliases
- Connexion Stalwart (clé API)
- Récupérer id domaine
- cinay.eu
- yannig.net
- yick.eu
- Récupérer id compte
- yani
- yann
- yick
- Voir les alias déjà existants sur le compte
- Compte yani
- Générer le fichier NDJSON
- Script alias-script.py
- Générer alias-plan.ndjson
- Ajouter les alias
- Exécution sans modification
- Appliquer les modifications
- Vérifications et tests
- 1. Vérifier qu'un alias accepte bien le mail (test positif)
- 2. Vérifier qu'une adresse aléatoire est bien rejetée (le vrai test du catch-all désactivé)
- 3. Vérifier côté serveur (logs / CLI)
- Résumé du test complet à faire
- Stalwart blockedip et spamtag
- Bloquer une adresse IP
- Adapter FORGEDRECIPIENTS
- Stalwart scripts
- Parser les entêtes 'To'
- Script python (destinataires.py)
- Parser entêtes 'X-Spam-Status' et 'X-Spam-Result'
- Script python (analysespamheaders.py)
- Script python (imapspamheaders.py)
Stalwart Mail Aliases
[!INFO] Ajouter ou modifier les alias d’un compte de messagerie
Connexion Stalwart (clé API)
[!IMPORTANT] Utiliser une clé API plutôt qu’un mot de passe** (recommandé pour l’automatisation) Va dans le WebUI → ton compte admin → Credentials → API Keys, crée une clé (la clé API remplace le couple user/password — vérifie la syntaxe exacte attendue par ta version avec
stalwart-cli --help, ça peut être-c user:cléou juste--passwordselon la build).
Exporter les variables
- export STALWART_URL=https://yannig.net
- export STALWART_TOKEN=’API_xxxxxxxxxxxxxxxxx’
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export STALWART_URL=https://mail.example.com
# export STALWART_USER=admin
# export STALWART_PASSWORD='changeme'
export STALWART_TOKEN='CLE API'
stalwart-cli describe # list every available object
stalwart-cli describe domain # full schema for one object
stalwart-cli query domain # default columns
stalwart-cli get domain <id> # full object
stalwart-cli create domain --field name=example.com --field isEnabled=true
stalwart-cli update domain <id> --field description='Primary'
stalwart-cli delete domain --ids <id>
stalwart-cli apply --file plan.json # bulk apply
stalwart-cli snapshot Tenant Domain \ # export state as an apply plan
--output backup.json
Connexion avec clé API
1
stalwart-cli describe account
Si une variable STALWART_USER est active dans la session, le CLI comprend une authentification via clé API (avec --password) tout en ayant un --user déjà défini — d’où le conflit. Il faut supprimer STALWART_USER de l’environnement
1
unset STALWART_USER
Récupérer id domaine
Tous les domaines
1
stalwart-cli query domain
1
2
3
4
Id Domain Name Enabled Certificate Management DNS Management
d cinay.eu Yes ACME TLS certificate management Automatic DNS management
c yick.eu Yes ACME TLS certificate management Automatic DNS management
b yannig.net Yes ACME TLS certificate management Automatic DNS management
cinay.eu
1
stalwart-cli query domain --where text=cinay.eu
id retourné
1
2
Id Domain Name Enabled Certificate Management DNS Management
d cinay.eu Yes ACME TLS certificate management Automatic DNS management
yannig.net
1
stalwart-cli query domain --where text=yannig.net
id retourné
1
2
Id Domain Name Enabled Certificate Management DNS Management
b yannig.net Yes ACME TLS certificate management Automatic DNS management
yick.eu
1
stalwart-cli query domain --where text=yick.eu
id retourné
1
2
Id Domain Name Enabled Certificate Management DNS Management
c yick.eu Yes ACME TLS certificate management Automatic DNS management
Récupérer id compte
Tous les comptes
1
stalwart-cli query account
1
2
3
4
5
Id Email Address Description Created At
e yani@cinay.eu yani cinay 2026-04-24T09:32:29Z
d yick@yick.eu stalwart yick 2026-04-24T06:44:21Z
c yann@yannig.net yannick 2026-04-21T17:56:19Z
b staladmin@yannig.net Administrateur Système 2026-04-21T13:55:22Z
yani
1
stalwart-cli query account --where name=yani
ID
1
2
Id Email Address Full Name Created At
e yani@cinay.eu yani cinay 2026-04-24T09:32:29Z
yann
1
stalwart-cli query account --where name=yann
ID
1
2
Id Email Address Full Name Created At
c yann@yannig.net yannick 2026-04-21T17:56:19Z
yick
1
stalwart-cli query account --where name=yick
ID
1
2
Id Email Address Description Created At
d yick@yick.eu stalwart yick 2026-04-24T06:44:21Z
Voir les alias déjà existants sur le compte
Compte yani
1
2
# Syntaxe: stalwart-cli get account <id_compte> --json
stalwart-cli get account e --json |jq # jq pour la mise en forme json
alias
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
"name": "yani",
"domainId": "d",
"credentials": {
"0": {
"credentialId": "a",
"secret": "****",
"expiresAt": null,
"allowedIps": {},
"@type": "Password"
}
},
"createdAt": "2026-04-24T09:32:29Z",
"memberGroupIds": {},
"memberTenantId": null,
"roles": {
"@type": "User"
},
"permissions": {
"@type": "Inherit"
},
"quotas": {},
"aliases": {},
"externalId": null,
"description": "yani cinay",
"locale": "fr-FR",
"timeZone": "Europe/Paris",
"encryptionAtRest": {
"@type": "Disabled"
},
"@type": "User",
"usedDiskQuota": 4184983,
"emailAddress": "yani@cinay.eu",
"id": "e"
}
Regarde le champ "aliases" dans le JSON retourné — s’il y en a déjà, il faudra les inclure dans la liste finale (un update remplace tout le champ, il ne l’complète pas).
Générer le fichier NDJSON
Script alias-script.py
Créer un script python pour générer la table des alias du domaine cinay.eu compte yani et fichier csv (format ‘alias’ ou ‘alias;cible’)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#!/usr/bin/env python3
import sys
import json
import csv
def main():
if len(sys.argv) != 4:
print("Usage: python3 build_alias_plan.py <domain_id> <account_id> <fichier.csv>")
print("Le CSV attendu : une ligne par alias, format 'alias' ou 'alias;cible' (la cible est ignorée, tout pointe vers account_id)")
sys.exit(1)
domain_id = sys.argv[1]
account_id = sys.argv[2]
csv_path = sys.argv[3]
aliases = []
with open(csv_path, newline="") as f:
reader = csv.reader(f, delimiter=";")
for row in reader:
if not row or not row[0].strip():
continue
local_part = row[0].strip()
aliases.append({"name": local_part, "domainId": domain_id})
if not aliases:
print("Aucun alias trouvé dans le CSV.")
sys.exit(1)
aliases_encoded = {str(i): a for i, a in enumerate(aliases)}
record = {
"@type": "update",
"object": "Account",
"id": account_id,
"value": {
"aliases": aliases_encoded
}
}
with open("alias-plan.ndjson", "w") as f:
f.write(json.dumps(record) + "\n")
print(f"{len(aliases)} alias écrits dans alias-plan.ndjson")
if __name__ == "__main__":
main()
Syntaxe script pour génèrer le fichier alias-plan.ndjson.
1
python3 alias-script.py IDdomaine IDcompte fichierCSV
Générer alias-plan.ndjson
Génèrer le fichier alias-plan.ndjson
1
2
# domaine cinay.eu: d, compte yani: e, Fichier CSV: aliases.csv
python3 alias-script.py d e aliases.csv
Résultat avec le nombre d’alias
1
48 alias écrits dans alias-plan.ndjson
Extrait fichier cat alias-plan.ndjson |jq |more
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
"@type": "update",
"object": "Account",
"id": "e",
"value": {
"aliases": {
"0": {
"name": "audi",
"domainId": "d"
},
"1": {
"name": "boproyan",
"domainId": "d"
},
"2": {
"name": "buyonrakuten",
"domainId": "d"
},
"3": {
"name": "centreon",
"domainId": "d"
},
"4": {
"name": "cloud",
"domainId": "d"
},
"5": {
"name": "cnx",
"domainId": "d"
},
"6": {
"name": "debyan",
"domainId": "d"
},
Ajouter les alias
Exécution sans modification
1
stalwart-cli apply --file alias-plan.ndjson --dry-run
Résultat
1
2
Plan: 0 destroy, 1 update, 0 create, 0 upsert, 0 reconcile (0 objects)
(dry run: no changes will be made)
Appliquer les modifications
1
stalwart-cli apply --file alias-plan.ndjson
Résultat
1
2
3
Plan: 0 destroy, 1 update, 0 create, 0 upsert, 0 reconcile (0 objects)
✓ updated Account (1)
Done: 0 destroyed, 1 updated, 0 created (0 failed)
Vérifications et tests
Confirmer l’état exact du compte et du domaine directement :
1
2
3
4
# Vérifier présence des alias
stalwart-cli get account e --json | grep -A 200 aliases |jq
# catch all
stalwart-cli get domain d --json |jq
catch all est null
1
2
3
4
5
6
7
},
"memberTenantId": null,
"directoryId": null,
"catchAllAddress": null,
"subAddressing": {
"@type": "Enabled"
},
Le champ catch-all du domaine doit être vide/absent.
Test des alias, plusieurs méthodes, du plus simple au plus rigoureux
1. Vérifier qu’un alias accepte bien le mail (test positif)
Envoi réel : envoie un email depuis une adresse externe (Gmail, etc.) vers un des alias, ex. discord@cinay.eu, et vérifie qu’il arrive bien dans la boîte yani@cinay.eu.
Test SMTP direct avec swaks (plus rapide, sans dépendre d’un vrai client mail) :
1
swaks --to discord@cinay.eu --from test@example.com --server yannig.net
Si swaks n’est pas installé : sudo pacman -S swaks (ou apt install swaks selon ta distro).
2. Vérifier qu’une adresse aléatoire est bien rejetée (le vrai test du catch-all désactivé)
C’est le test le plus important, car c’est ce que tu cherches à confirmer :
1
swaks --to nimportequoi123@cinay.eu --from test@example.com --server yannig.net
On dois voir une erreur du type 550 5.1.1 User unknown (ou équivalent) — si l’email est accepté, le catch-all est encore actif quelque part (vérifie qu’il est bien vide sur le domaine dans le WebUI).
3. Vérifier côté serveur (logs / CLI)
Les logs sont au jour format /var/log/stalwart.aaaa-mm-jj
1
sudo -u stalwart tail -f /var/log/stalwart/stalwart.2026-09-07
Résumé du test complet à faire
- Un alias existant (
discord@cinay.eu) → doit être accepté et livré àyani. - Une adresse inexistante (
azerty999@cinay.eu) → doit être rejetée. yani@cinay.eului-même → doit toujours fonctionner normalement.
Si le point 2 échoue (l’email est accepté alors qu’il ne devrait pas), le problème vient très probablement du catch-all pas totalement vidé — vérifie dans Management → Directory → Domains → cinay.eu que le champ est bien vide et sauvegardé.
Stalwart blockedip et spamtag
Serveur de messagerie yannig.net, comment se fait que je reçois ce genre de message (source) alors que je ne suis pas destinataire1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
X-Mozilla-Status: 0001
X-Mozilla-Status2: 00000000
Delivered-To: yann@cinay.eu
X-Spam-Status: No
Received: from mx.wilde-wood.skin (mx.wilde-wood.skin [62.173.142.230] (AS34300 Internet-Cosmos LLC, RU))
(using TLSv1.2 with cipher TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384)
by yannig.net (Stalwart SMTP) with ESMTPS id 48F0A3919602800;
Wed, 9 Sep 2026 01:30:48 +0000
Authentication-Results: yannig.net;
spf=none (yannig.net: no SPF records found for postmaster@mx.wilde-wood.skin) smtp.helo=mx.wilde-wood.skin;
spf=pass (yannig.net: domain of ykfykjs@future-edu.space designates 62.173.142.230 as permitted sender) smtp.mailfrom=ykfykjs@future-edu.space;
iprev=pass policy.iprev=62.173.142.230;
dmarc=pass header.from=future-edu.space policy.dmarc=none
Received-SPF: pass (yannig.net: domain of ykfykjs@future-edu.space designates 62.173.142.230 as permitted sender)
receiver=yannig.net; client-ip=62.173.142.230; envelope-from="ykfykjs@future-edu.space"; helo=mx.wilde-wood.skin;
X-Spam-Result: DMARC_POLICY_ALLOW (-0.50),
SPF_ALLOW (-0.20),
MIME_GOOD (-0.10),
ARC_NA (0.00),
DBL_BLOCKED_OPENRESOLVER (0.00),
DKIM2_NA (0.00),
DKIM_NA (0.00),
DNSWL_BLOCKED (0.00),
FROM_EQ_ENV_FROM (0.00),
FROM_HAS_DN (0.00),
HAS_ATTACHMENT (0.00),
HTML_SHORT_1 (0.00),
MID_RHS_MATCH_ENV_FROMTLD (0.00),
MID_RHS_MATCH_FROMTLD (0.00),
RBL_SENDERSCORE_REPUT_BLOCKED (0.00),
RCPT_COUNT_ONE (0.00),
RCVD_TLS_LAST (0.00),
SOURCE_ASN_34300 (0.00),
TO_DN_NONE (0.00),
RCVD_COUNT_ZERO (0.10),
SUBJECT_ENDS_QUESTION (1.00),
FORGED_RECIPIENTS (2.00)
X-Spam-Score: ham, score=2.30
Return-Path: <ykfykjs@future-edu.space>
Message-ID: <68531475R12344253A40256407Q31622362E@mta.ykfykjs.future-edu.space>
From: "NooBark" <ykfykjs@future-edu.space>
To: <labas@christmas2business.eu>
Subject: Dog barking all the time?
Date: Tue, 08 Sep 2026 17:49:13 +0300
MIME-Version: 1.0
Content-Type: multipart/related;
type="multipart/alternative";
boundary="----=_NextPart_000_0006_01DD3FB9.5F5CCD80"
This is a multi-part message in MIME format.
Pourquoi ça arrive quand même dans ta boîte
Il faut distinguer deux choses en SMTP :
- L’enveloppe SMTP (commandes
MAIL FROM/RCPT TO) — c’est elle qui détermine réellement à qui le message est délivré. On le voit dansDelivered-To: yann@cinay.eu: c’est bien ton adresse qui a été indiquée comme destinataire au niveau de l’enveloppe. - L’en-tête
To:visible dans le message (To: <labas@christmas2business.eu>) — c’est juste du texte dans le corps des headers, que l’expéditeur peut écrire ce qu’il veut. Rien n’oblige que ton adresse y figure.
Les spammeurs envoient souvent le même message à des dizaines/centaines de destinataires en une seule session SMTP (ou via des scripts), et laissent un To: bidon, générique ou appartenant à quelqu’un d’autre — parfois pour brouiller les pistes, parfois simplement parce que le header vient d’une liste différente de la liste d’enveloppe réelle. Ton serveur reçoit quand même le mail car techniquement l’enveloppe dit bien « livre ça à yann@cinay.eu ».
Autres indices de spam dans ce message
- Domaine expéditeur
future-edu.spaceenvoyé depuis un serveur nommémx.wilde-wood.skinhébergé chez un fournisseur russe peu réputé (AS34300). - Aucun DKIM (
DKIM_NA), uniquement SPF qui passe — facile à obtenir avec un domaine qu’on contrôle soi-même. - Liens raccourcis en Punycode cyrillique (
xn--c1atq.xn--b1apmck...) qui redirigent vers un site de vente, classique pour ce genre de pub (ici un collier anti-aboiement). RCPT_COUNT_ONE+FORGED_RECIPIENTS: un seul destinataire réel en enveloppe, mais headerTo:qui ne correspond pas — signature typique des campagnes de spam en masse.
Ce que tu peux faire
- Le score global est resté à 2.30 (ham), donc en dessous du seuil de rejet/déplacement en spam — tu peux resserrer les règles de ton filtre (Rspamd/Stalwart) en augmentant le poids de
FORGED_RECIPIENTSou en ajoutant une règle qui pénalise davantage les mails oùTo:ne correspond à aucune adresse locale connue. - Bloquer le domaine
future-edu.spaceet l’IP62.173.142.230si ça persiste.
Bloquer une adresse IP
Plutôt que de deviner les noms de champs, vérifie-les directement — plus fiable que ce que je pourrais t’affirmer de mémoire sur cet objet précis :
1
stalwart-cli describe blockedip
Ça te donnera la liste exacte des champs (adresse/CIDR, raison, expiration éventuelle) et leur nom précis dans ta version.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
BlockedIp
Defines a blocked IP address or network range.
Fields:
address string<ipNetwork> immutable
The IP address or mask to block
createdAt datetime server-set
The date and time when this IP address was blocked
expiresAt datetime? mutable
The date and time when this IP address block will expire
reason enum (see BlockReason) mutable
The reason for blocking this IP address
Filters:
address text Address
reason n’est pas un texte libre, c’est une enum (BlockReason) avec des valeurs fixes : authFailure, rcptToFailure, loitering, portScanning, manual, ou other. Il n’y a pas de commentaire libre possible.
Pour ton blocage manuel, il faut juste mettre manual (qui est déjà la valeur par défaut, donc tu peux même l’omettre) :
1
stalwart-cli create blockedip --field address=62.173.142.230/32
ou explicitement :
1
stalwart-cli create blockedip --field address=62.173.142.230/32 --field reason=manual
Tu peux éventuellement ajouter une expiration si tu ne veux pas que ce soit permanent :
1
stalwart-cli create blockedip --field address=62.173.142.230/32 --field expiresAt=2026-12-31T00:00:00Z
Le blocage IP est en place. Recharge maintenant la liste pour que ça s’applique immédiatement, sans attendre le prochain cycle automatique :
1
stalwart-cli create action --field '@type=ReloadBlockedIps'
Tu peux vérifier à tout moment l’état du blocage avec :
1
stalwart-cli get blockedip jdy2wvy7aqaa
1
2
3
4
5
Block IP address
IP Address: 62.173.142.230
Reason for blocking: Manually blocked IP address
Expiration date: 2026-12-31T00:00:00Z
Created At: 2026-09-09T04:12:20Z
Adapter FORGED_RECIPIENTS
On repart sur l’ajustement du tag FORGED_RECIPIENTS, pour couvrir les prochaines variantes de la campagne (autres IP que celle déjà bloquée).
Lance :
1
stalwart-cli query spamtag --where name=FORGED_RECIPIENTS --json
Le champ name n’est pas filtrable en --where pour cet objet (chaque objet a son propre jeu de filtres supportés). Deux options :
1. Voir les filtres réellement supportés pour spamtag :
1
stalwart-cli describe spamtag
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SpamTag
Defines a score or action assigned to a spam classification tag.
Variants:
Score: Assign Score
Fields:
score number<float> mutable
The score for the tag
tag string<string> mutable
The spam tag name
Discard: Discard
Fields:
tag string<string> mutable
The spam tag name
Reject: Reject
Fields:
tag string<string> mutable
The spam tag name
Filters:
tag text Tag name
Bien vu — le champ s’appelle tag, pas name. Relance avec le bon nom :
1
stalwart-cli query spamtag --where tag=FORGED_RECIPIENTS --fields id,tag,score --json
1
{"tag":"FORGED_RECIPIENTS","score":2.0,"id":"ine9rrcccxaa"}
Confirmé : le tag est en variante Score (pas Discard/Reject), poids actuel 2.0.
On reste dans la même variante, donc une simple mise à jour du champ suffit :
1
stalwart-cli update spamtag ine9rrcccxaa --field score=3
Vérifie que le changement a bien pris :
1
stalwart-cli get spamtag ine9rrcccxaa
1
2
3
4
5
Type: Assign Score
Tag
Tag name: FORGED_RECIPIENTS
Score: 3
Ensuite, laisse tourner 48h et surveille les logs
Description log
1
stalwart-cli describe log
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Log
Represents a server log entry.
Fields:
details string<text> mutable
Log message
event enum (see EventType) mutable
Event type of the log entry
level enum (see TracingLevel) mutable
Severity level of the log entry
timestamp datetime mutable
Timestamp of the log entry
Filters:
text text Text
La commande de surveillance des logs
1
stalwart-cli query log --where event=spam-filter --fields timestamp,event,details
Si tu ne vois pas de faux positif sur cette période et que le spam à FORGED_RECIPIENTS continue de passer sous le seuil de 5, tu pourras remonter encore un cran (3 → 4). Si tu observes qu’un mail légitime a été impacté, redescends immédiatement à 2 :
1
stalwart-cli update spamtag ine9rrcccxaa --field score=2
La commande update fonctionne
1
stalwart-cli update spamtag ine9rrcccxaa --field score=3
Le tag FORGED_RECIPIENTS est maintenant à 3.0 au lieu de 2.0.
Stalwart scripts
Dans ma boite de réception thunderbird sous linux , je souhaite récupérer en ligne de commande la liste les destinataires (to) d'un domaine donné Le plus fiable est de parser les en-têtes `To:` plutôt que de faire un simple `grep`, car les en-têtes peuvent être répartis sur plusieurs lignes.Parser les entêtes ‘To’
Script python (destinataires.py)
Enregistrez ce script dans destinataires.py :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#!/usr/bin/env python3
import os
import sys
import mailbox
from email import policy
from email.parser import BytesParser
from email.utils import getaddresses
if len(sys.argv) != 3:
print(f"Usage : {sys.argv[0]} <dossier-thunderbird> <domaine>")
sys.exit(1)
dossier = sys.argv[1]
domaine = sys.argv[2].lower().lstrip("@")
trouves = set()
def traiter(message):
for _, adresse in getaddresses(message.get_all("to", [])):
adresse = adresse.strip().lower()
if "@" in adresse and adresse.rsplit("@", 1)[1] == domaine:
trouves.add(adresse)
for racine, _, fichiers in os.walk(dossier):
for nom in fichiers:
if nom.endswith(".msf") or nom.startswith("."):
continue
chemin = os.path.join(racine, nom)
# Cas habituel de Thunderbird : fichier mbox
try:
boite = mailbox.mbox(chemin, create=False)
for message in boite:
traiter(message)
boite.close()
continue
except Exception:
pass
# Cas éventuel : message individuel au format Maildir
try:
with open(chemin, "rb") as fichier:
message = BytesParser(policy=policy.default).parse(fichier)
traiter(message)
except Exception:
pass
for adresse in sorted(trouves):
print(adresse)
Rendez-le exécutable :
1
chmod +x destinataires.py
Trouvez ensuite le profil Thunderbird :
1
find ~/.thunderbird -type d -name "Mail"
Puis lancez, par exemple, pour le domaine exemple.com :
1
./destinataires.py "$HOME/.thunderbird/xxxxxxxx.default-release/Mail" exemple.com
La sortie sera une adresse par ligne :
1
2
3
alice@exemple.com
contact@exemple.com
support@exemple.com
Pour compter les occurrences de chaque adresse :
1
2
./destinataires.py "$HOME/.thunderbird/xxxxxxxx.default-release/Mail" exemple.com \
| sort | uniq -c | sort -nr
Si vous voulez seulement examiner la boîte de réception locale, indiquez plutôt son chemin, par exemple :
1
2
3
./destinataires.py \
"$HOME/.thunderbird/xxxxxxxx.default-release/Mail/Local Folders" \
exemple.com
Pour les comptes IMAP, les messages se trouvent généralement sous un chemin similaire à :
1
~/.thunderbird/<profil>/ImapMail/<serveur>/
Vous pouvez donc lancer le script directement sur tout le dossier du profil Mail ou ImapMail. Le script gère les adresses écrites comme Nom <utilisateur@exemple.com> et les en-têtes To: multilignes.
Parser entêtes ‘X-Spam-Status’ et ‘X-Spam-Result’
Script python (analyse_spam_headers.py)
Le script python analyse_spam_headers.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
#!/usr/bin/env python3
"""
Parcourt une boîte Thunderbird (mbox ou Maildir) et rapporte, pour chaque
message, le statut et le détail du score du filtre anti-spam Stalwart
(en-têtes X-Spam-Status / X-Spam-Result), afin de suivre l'effet d'un
ajustement de filtre (ex: poids du tag FORGED_RECIPIENTS) dans le temps.
Usage :
./analyse_spam_headers.py <dossier-thunderbird> [--tag NOM_TAG]
[--since AAAA-MM-JJ]
[--min-score SCORE]
Exemples :
# Tous les messages qui portent le tag FORGED_RECIPIENTS
./analyse_spam_headers.py ~/.thunderbird/xxxx.default/Mail/yannig.net \\
--tag FORGED_RECIPIENTS
# Uniquement depuis le jour où le score du tag a été relevé
./analyse_spam_headers.py ~/.thunderbird/xxxx.default/Mail/yannig.net \\
--tag FORGED_RECIPIENTS --since 2026-09-09
# Tous les messages dont le score total dépasse 4 (pour repérer les
# faux positifs proches du seuil de 5)
./analyse_spam_headers.py ~/.thunderbird/xxxx.default/Mail/yannig.net \\
--min-score 4
"""
import argparse
import mailbox
import os
import re
import sys
from datetime import datetime
from email import policy
from email.parser import BytesParser
from email.utils import parsedate_to_datetime
RE_STATUS = re.compile(r"^\s*(Yes|No)\b", re.IGNORECASE)
RE_SCORE = re.compile(r"score\s*=\s*([-\d.]+)", re.IGNORECASE)
RE_TAG = re.compile(r"([A-Z0-9_]+)\s*\(([-\d.]+)\)")
def parser_arguments():
p = argparse.ArgumentParser(description="Analyse des en-têtes anti-spam Stalwart dans une boîte Thunderbird")
p.add_argument("dossier", help="Dossier de profil Thunderbird (ou sous-dossier Mail) à parcourir")
p.add_argument("--tag", help="N'afficher que les messages portant ce tag (ex: FORGED_RECIPIENTS)")
p.add_argument("--since", help="N'afficher que les messages reçus à partir de cette date (AAAA-MM-JJ)")
p.add_argument("--min-score", type=float, help="N'afficher que les messages dont le score total est >= à cette valeur")
return p.parse_args()
def extraire_infos(message):
"""Retourne un dict avec date, expéditeur, statut, score total et tags, ou None si pas d'en-tête spam."""
status_header = message.get("X-Spam-Status")
score_header = message.get("X-Spam-Score")
result_header = message.get("X-Spam-Result")
if not status_header:
return None
match_status = RE_STATUS.match(status_header)
if not match_status:
return None
est_spam = match_status.group(1).lower() == "yes"
score_total = None
for source in (score_header, status_header):
if source:
match_score = RE_SCORE.search(source)
if match_score:
score_total = float(match_score.group(1))
break
tags = dict()
if result_header:
for nom_tag, score_tag in RE_TAG.findall(result_header):
tags[nom_tag] = float(score_tag)
date_message = None
date_header = message.get("Date")
if date_header:
try:
date_message = parsedate_to_datetime(date_header)
except (TypeError, ValueError):
date_message = None
return {
"date": date_message,
"de": message.get("From", "?"),
"sujet": message.get("Subject", "?"),
"spam": est_spam,
"score": score_total,
"tags": tags,
}
def filtrer(info, tag_recherche, depuis, score_min):
if tag_recherche and tag_recherche not in info["tags"]:
return False
if depuis and (info["date"] is None or info["date"].date() < depuis):
return False
if score_min is not None and (info["score"] is None or info["score"] < score_min):
return False
return True
def afficher(info, tag_recherche):
date_txt = info["date"].strftime("%Y-%m-%d %H:%M") if info["date"] else "date inconnue"
statut_txt = "SPAM" if info["spam"] else "ham "
score_txt = f"{info['score']:5.2f}" if info["score"] is not None else " ?.??"
detail_tag = ""
if tag_recherche and tag_recherche in info["tags"]:
detail_tag = f" [{tag_recherche}={info['tags'][tag_recherche]:.2f}]"
print(f"{date_txt} {statut_txt} score={score_txt}{detail_tag} de={info['de']} sujet={info['sujet'][:60]}")
def parcourir_message(message, tag_recherche, depuis, score_min):
info = extraire_infos(message)
if info is None:
return
if filtrer(info, tag_recherche, depuis, score_min):
afficher(info, tag_recherche)
def main():
args = parser_arguments()
depuis = None
if args.since:
depuis = datetime.strptime(args.since, "%Y-%m-%d").date()
if not os.path.isdir(args.dossier):
print(f"Erreur : le dossier '{args.dossier}' n'existe pas.", file=sys.stderr)
sys.exit(1)
nb_fichiers = 0
nb_boites_mbox = 0
nb_messages_total = 0
nb_messages_avec_entete = 0
nb_messages_affiches = 0
for racine, _, fichiers in os.walk(args.dossier):
for nom in fichiers:
if nom.endswith(".msf") or nom.startswith("."):
continue
chemin = os.path.join(racine, nom)
nb_fichiers += 1
# Cas habituel de Thunderbird : fichier mbox
try:
boite = mailbox.mbox(chemin, create=False)
messages = list(boite)
if messages:
nb_boites_mbox += 1
for message in messages:
nb_messages_total += 1
info = extraire_infos(message)
if info is not None:
nb_messages_avec_entete += 1
if filtrer(info, args.tag, depuis, args.min_score):
afficher(info, args.tag)
nb_messages_affiches += 1
boite.close()
continue
except Exception:
pass
# Cas éventuel : message individuel au format Maildir
try:
with open(chemin, "rb") as fichier:
message = BytesParser(policy=policy.default).parse(fichier)
nb_messages_total += 1
info = extraire_infos(message)
if info is not None:
nb_messages_avec_entete += 1
if filtrer(info, args.tag, depuis, args.min_score):
afficher(info, args.tag)
nb_messages_affiches += 1
except Exception:
pass
print(
f"\n--- {nb_fichiers} fichiers parcourus, {nb_boites_mbox} boîtes mbox non vides, "
f"{nb_messages_total} messages lus, {nb_messages_avec_entete} avec en-tête X-Spam-Status, "
f"{nb_messages_affiches} affichés ---",
file=sys.stderr,
)
if __name__ == "__main__":
main()
Trois usages utiles pour ton suivi :
1
2
3
4
5
6
7
8
# Tous les messages portant le tag FORGED_RECIPIENTS, avec son score détaillé
./analyse_spam_headers.py ~/.thunderbird/xxxx.default/Mail/yannig.net --tag FORGED_RECIPIENTS
# Uniquement depuis le jour où tu as relevé le score (2 -> 3)
./analyse_spam_headers.py ~/.thunderbird/xxxx.default/Mail/yannig.net --tag FORGED_RECIPIENTS --since 2026-09-09
# Repérer les mails proches du seuil de 5 (surveillance des faux positifs)
./analyse_spam_headers.py ~/.thunderbird/xxxx.default/Mail/yannig.net --min-score 4
La première commande te donne exactement ce qu’il te faut pour ton suivi sur 48h : liste chaque mail avec FORGED_RECIPIENTS=X.XX, le score total, l’expéditeur et si c’est classé SPAM ou ham. Si tu vois un ham avec un score qui grimpe dangereusement près de 5 à cause de ce tag, c’est ton signal d’alerte pour redescendre à 2.
Adapte le chemin ~/.thunderbird/xxxx.default/Mail/yannig.net à ton profil réel (le dossier contenant les fichiers de boîte pour ton compte).
Je rajoute aussi un compteur de diagnostic au script pour éviter ce genre de silence énigmatique à l’avenir (nombre de fichiers parcourus, nombre de boîtes mbox ouvertes, nombre de messages avec en-tête spam trouvé) :Relance avec le bon dossier (ImapMail, pas Mail) :
1
./analyse_spam_headers.py "$HOME/.thunderbird/xxxx.default/ImapMail" --tag FORGED_RECIPIENTS
Résultat
1
2
3
4
5
6
7
8
2026-08-23 15:26 ham score= 3.70 [FORGED_RECIPIENTS=2.00] de=BTP GESTION COMPTA <info2whoer026@emeralestore.com> sujet==?utf-8?q?devis_sign=C3=A9?=
2026-09-01 17:33 ham score= 1.30 [FORGED_RECIPIENTS=2.00] de="Ondrela" <esjaydc@future-edu.space> sujet=Looks ordinary. Tracks like nothing else.
[...]
2026-09-08 17:49 ham score= 2.30 [FORGED_RECIPIENTS=2.00] de="NooBark" <ykfykjs@future-edu.space> sujet=Dog barking all the time?
2026-07-29 03:31 ham score= 4.90 [FORGED_RECIPIENTS=2.00] de=TRUST WALLET <info-hissen2054@emeralestore.com> sujet==?utf-8?q?Action_required=3A_Verify_your_Trust_Wallet_identi
2026-09-01 17:33 ham score= 1.30 [FORGED_RECIPIENTS=2.00] de="Ondrela" <esjaydc@future-edu.space> sujet=Looks ordinary. Tracks like nothing else.
--- 248 fichiers parcourus, 172 boîtes mbox non vides, 13363 messages lus, 676 avec en-tête X-Spam-Status, 32 affichés ---
À la fin, il affiche maintenant sur stderr un résumé (--- N fichiers parcourus, N boîtes mbox non vides, N messages lus, N avec en-tête X-Spam-Status, N affichés ---). Ça te dira immédiatement où ça coince s’il n’y a toujours rien :
0 fichiers parcourus→ mauvais chemin (vérifie avecls "$HOME/.thunderbird/xxxx.default/ImapMail"pour voir le nom exact du sous-dossier compte).- Fichiers parcourus mais
0 boîtes mbox non vides→ le format n’est peut-être pas du mbox standard chez toi, ou les fichiers sont ailleurs (INBOX.sbd/,Junk, etc. — le script descend récursivement donc ça devrait les trouver). - Boîtes lues mais
0 avec en-tête X-Spam-Status→ soit les mails de ce dossier ne sont pas passés par le filtre Stalwart (Sent, Drafts…), soit l’en-tête a un nom légèrement différent chez toi.
Script python (imap_spam_headers.py)
Script imap_spam_headers.py pour lire X-Spam-Score
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
#!/usr/bin/env python3
"""
Se connecte directement en IMAP (SSL) à un compte et rapporte, pour chaque
message d'un dossier, le statut et le détail du score du filtre anti-spam
Stalwart (en-têtes X-Spam-Status / X-Spam-Result), sans dépendre du cache
local de Thunderbird.
Le mot de passe est demandé de façon interactive (jamais en argument de
ligne de commande, pour éviter qu'il traîne dans l'historique du shell ou
dans `ps`).
Usage :
./imap_spam_headers.py <serveur> <utilisateur> [--dossier INBOX]
[--tag NOM_TAG]
[--since AAAA-MM-JJ]
[--min-score SCORE]
Exemple :
./imap_spam_headers.py imap.yannig.net yann@cinay.eu \\
--dossier INBOX --tag FORGED_RECIPIENTS --since 2026-09-01
"""
import argparse
import getpass
import imaplib
import re
import sys
from datetime import datetime
from email import message_from_bytes, policy
from email.utils import parsedate_to_datetime
RE_STATUS = re.compile(r"^\s*(Yes|No)\b", re.IGNORECASE)
RE_SCORE = re.compile(r"score\s*=\s*([-\d.]+)", re.IGNORECASE)
RE_TAG = re.compile(r"([A-Z0-9_]+)\s*\(([-\d.]+)\)")
def parser_arguments():
p = argparse.ArgumentParser(description="Analyse à distance des en-têtes anti-spam Stalwart via IMAP")
p.add_argument("serveur", help="Nom d'hôte du serveur IMAP (ex: imap.yannig.net)")
p.add_argument("utilisateur", help="Adresse / identifiant IMAP")
p.add_argument("--port", type=int, default=993, help="Port IMAP SSL (défaut: 993)")
p.add_argument("--dossier", default="INBOX", help="Dossier IMAP à analyser (défaut: INBOX)")
p.add_argument("--tag", help="N'afficher que les messages portant ce tag (ex: FORGED_RECIPIENTS)")
p.add_argument("--since", help="N'afficher que les messages reçus à partir de cette date (AAAA-MM-JJ)")
p.add_argument("--min-score", type=float, help="N'afficher que les messages dont le score total est >= à cette valeur")
return p.parse_args()
def extraire_infos(entetes):
status_header = entetes.get("X-Spam-Status")
score_header = entetes.get("X-Spam-Score")
result_header = entetes.get("X-Spam-Result")
if not status_header:
return None
match_status = RE_STATUS.match(status_header)
if not match_status:
return None
est_spam = match_status.group(1).lower() == "yes"
score_total = None
for source in (score_header, status_header):
if source:
match_score = RE_SCORE.search(source)
if match_score:
score_total = float(match_score.group(1))
break
tags = dict()
if result_header:
for nom_tag, score_tag in RE_TAG.findall(result_header):
tags[nom_tag] = float(score_tag)
date_message = None
date_header = entetes.get("Date")
if date_header:
try:
date_message = parsedate_to_datetime(date_header)
except (TypeError, ValueError):
date_message = None
return {
"date": date_message,
"de": entetes.get("From", "?"),
"sujet": entetes.get("Subject", "?"),
"spam": est_spam,
"score": score_total,
"tags": tags,
}
def filtrer(info, tag_recherche, score_min):
if tag_recherche and tag_recherche not in info["tags"]:
return False
if score_min is not None and (info["score"] is None or info["score"] < score_min):
return False
return True
def afficher(info, tag_recherche):
date_txt = info["date"].strftime("%Y-%m-%d %H:%M") if info["date"] else "date inconnue"
statut_txt = "SPAM" if info["spam"] else "ham "
score_txt = f"{info['score']:5.2f}" if info["score"] is not None else " ?.??"
detail_tag = ""
if tag_recherche and tag_recherche in info["tags"]:
detail_tag = f" [{tag_recherche}={info['tags'][tag_recherche]:.2f}]"
print(f"{date_txt} {statut_txt} score={score_txt}{detail_tag} de={info['de']} sujet={info['sujet'][:60]}")
def main():
args = parser_arguments()
depuis = None
if args.since:
depuis = datetime.strptime(args.since, "%Y-%m-%d").date()
mot_de_passe = getpass.getpass(f"Mot de passe / clé d'application pour {args.utilisateur} : ")
connexion = imaplib.IMAP4_SSL(args.serveur, args.port)
try:
connexion.login(args.utilisateur, mot_de_passe)
except imaplib.IMAP4.error as erreur:
print(f"Échec de connexion : {erreur}", file=sys.stderr)
sys.exit(1)
statut, _ = connexion.select(args.dossier, readonly=True)
if statut != "OK":
print(f"Impossible d'ouvrir le dossier '{args.dossier}'.", file=sys.stderr)
connexion.logout()
sys.exit(1)
criteres = "ALL"
if depuis:
# Format IMAP : 01-Jan-2026
criteres = f'(SINCE "{depuis.strftime("%d-%b-%Y")}")'
statut, donnees = connexion.search(None, criteres)
if statut != "OK":
print("Erreur lors de la recherche des messages.", file=sys.stderr)
connexion.logout()
sys.exit(1)
ids = donnees[0].split()
nb_messages_total = len(ids)
nb_messages_avec_entete = 0
nb_messages_affiches = 0
for id_message in ids:
statut, donnees_msg = connexion.fetch(
id_message,
"(BODY.PEEK[HEADER.FIELDS (X-Spam-Status X-Spam-Score X-Spam-Result From Subject Date)])",
)
if statut != "OK" or not donnees_msg or donnees_msg[0] is None:
continue
brut = donnees_msg[0][1]
entetes = message_from_bytes(brut, policy=policy.default)
info = extraire_infos(entetes)
if info is None:
continue
nb_messages_avec_entete += 1
if filtrer(info, args.tag, args.min_score):
afficher(info, args.tag)
nb_messages_affiches += 1
connexion.logout()
print(
f"\n--- {nb_messages_total} messages dans '{args.dossier}', "
f"{nb_messages_avec_entete} avec en-tête X-Spam-Status, "
f"{nb_messages_affiches} affichés ---",
file=sys.stderr,
)
if __name__ == "__main__":
main()
Lancé
1
scripts/imap_spam_headers.py yannig.net yann@cinay.eu --tag FORGED_RECIPIENTS --since 2026-09-09
Ça devrait maintenant afficher ce mail (score 2.30, FORGED_RECIPIENTS=2.00), et tous les suivants du même type — tu pourras suivre si leur score FORGED_RECIPIENTS passe bien à 3.00 après ton update spamtag.
1
2
3
2026-09-08 17:49 ham score= 2.30 [FORGED_RECIPIENTS=2.00] de=NooBark <ykfykjs@future-edu.space> sujet=Dog barking all the time?
--- 10 messages dans 'INBOX', 10 avec en-tête X-Spam-Status, 1 affichés ---
Si ça affiche bien la ligne attendue, tu as ton outil de suivi opérationnel :
- lance-le régulièrement dans les prochains jours
- surveille si le score
FORGED_RECIPIENTSdes nouveaux mails de spam passe bien à3.00(grâce à tonupdate spamtag), sans qu’aucun mail légitime (ham) n’apparaisse dans les résultats filtrés sur ce tag.