đŸ”·Stalwart TOTP + Aliases

đŸ”·Stalwart TOTP + Aliases

Notes

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 --password selon 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
Pour éviter ce genre de conflit à l'avenir, le plus simple est d'ouvrir un nouveau terminal propre à chaque fois que l'on change de mode d'authentification (user/password vs clé API), ou de faire un `env | grep STALWART` pour vérifier ce qui traßne avant de lancer une commande.

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
250 2.0.0 Message queued with id 48e22a627400400.

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
550 5.1.2 Mailbox does not exist.

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

  1. Un alias existant (discord@cinay.eu) → doit ĂȘtre acceptĂ© et livrĂ© Ă  yani.
  2. Une adresse inexistante (azerty999@cinay.eu) → doit ĂȘtre rejetĂ©e.
  3. yani@cinay.eu lui-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 destinataire
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
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.
C'est normal et c'est mĂȘme signalĂ© par ton propre filtre antispam : regarde le header `X-Spam-Result`, il contient `FORGED_RECIPIENTS (2.00)` — c'est-Ă -dire que Stalwart (ton serveur `yannig.net`) a dĂ©tectĂ© que l'en-tĂȘte `To:` est falsifiĂ©.

Pourquoi ça arrive quand mĂȘme dans ta boĂźte

Il faut distinguer deux choses en SMTP :

  1. 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 dans Delivered-To: yann@cinay.eu : c’est bien ton adresse qui a Ă©tĂ© indiquĂ©e comme destinataire au niveau de l’enveloppe.
  2. 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.space envoyĂ© depuis un serveur nommĂ© mx.wilde-wood.skin hĂ©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 header To: 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_RECIPIENTS ou en ajoutant une rĂšgle qui pĂ©nalise davantage les mails oĂč To: ne correspond Ă  aucune adresse locale connue.
  • Bloquer le domaine future-edu.space et l’IP 62.173.142.230 si ça persiste.

Bloquer une adresse IP

â„čBlocage IP immĂ©diat — 62.173.142.230 (source de la campagne "collier anti-aboiement") bloquĂ©e via `BlockedIp`, sans risque pour le reste du courrier.

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
Created BlockedIp jdy2wvy7aqaa

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'
Created Action bvkbxkg

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
â„čIP 62.173.142.230 (le serveur `mx.wilde-wood.skin` d'oĂč venait le mail collier anti-aboiement) sera rejetĂ©e Ă  la connexion — ce blocage n'a aucun effet sur le reste du courrier, donc pas de risque de reproduire l'incident de sur-blocage.

Adapter FORGED_RECIPIENTS

â„č — poids du tag FORGED_RECIPIENTS remontĂ© de 2 Ă  3, sans toucher aux seuils globaux (`scoreSpam`/`scoreDiscard`/`scoreReject` restent Ă  5/10/15).

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
Updated SpamTag ine9rrcccxaa
â„čUn incrĂ©ment prudent (2 → 3), comme prĂ©vu — pour ne pas faire basculer un mail lĂ©gitime qui n'aurait que ce seul tag isolĂ©, tout en renforçant son poids dans le score cumulĂ© pour les prochaines variantes de cette campagne.

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
Updated SpamTag ine9rrcccxaa

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).

Le dossier de comptes IMAP est bien `ImapMail/` chez Thunderbird, pas `Mail/` (qui ne sert qu'aux comptes locaux/POP)

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 avec ls "$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_RECIPIENTS des nouveaux mails de spam passe bien Ă  3.00 (grĂące Ă  ton update spamtag), sans qu’aucun mail lĂ©gitime (ham) n’apparaisse dans les rĂ©sultats filtrĂ©s sur ce tag.