Stalwart TOTP + Aliases

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