đ·Stalwart TOTP + Aliases
- Stalwart Mail Aliases
- Stalwart blockedip et spamtag
- Stalwart scripts
Stalwart Mail Aliases
Ajouter ou modifier les alias dâun compte de messagerie
Connexion Stalwart (clé API)
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.