Configuration

difuzio se configure par un fichier YAML unique (typiquement /etc/difuzio/config.yaml), partagé par les trois processus. Le fichier config.example.yaml à la racine du dépôt sert de modèle commenté.

Chargement et surcharge

Le fichier est validé une seule fois au démarrage. Une clé requise manquante ou un invariant cassé fait échouer le démarrage : il n'y a pas de valeur par défaut silencieuse. Pour vérifier sans démarrer :

CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio config check --config /etc/difuzio/config.yaml

Chaque clé peut être surchargée par une variable d'environnement préfixée DIFUZIO_, avec __ (double underscore) comme séparateur de niveau. Par exemple DIFUZIO_DB__DSN surcharge db.dsn, et DIFUZIO_RELAY__PORT surcharge relay.port. C'est utile pour injecter des secrets sans les écrire dans le fichier.

Section log

log:
  level: info        # debug|info|warn|error
  format: json       # json|text

Les journaux applicatifs sont structurés (slog). En production, format: json facilite l'ingestion. Les journaux peuvent contenir des données personnelles (adresses email) : aligner la rétention sur la politique RGPD (90 jours).

Section db

db:
  dsn: "difuzio:MOTDEPASSE@tcp(127.0.0.1:3306)/difuzio?parseTime=true&loc=UTC&time_zone=%27%2B00%3A00%27&clientFoundRows=true"
  max_open_conns: 25
  max_idle_conns: 5
  conn_max_lifetime: 5m

Le DSN (Data Source Name) est la chaîne de connexion à la base. Il référence le compte applicatif à moindre privilège, sans droits DDL (Data Definition Language : CREATE, ALTER, DROP) ; les migrations utilisent un compte DDL séparé passé à difuzio migrate --dsn. Le DSN doit porter parseTime=true, loc=UTC, time_zone='+00:00' et clientFoundRows=true ; ces paramètres ne sont pas optionnels.

Avec le binaire compilé pour SQLite, dsn est un simple chemin de fichier (/var/lib/difuzio/difuzio.db) : les pragmas et le mode de verrouillage sont posés par difuzio, il n'y a rien à ajouter. Voir Base de données.

Section storage

storage:
  blob_backend: fs
  blob_dir: /var/lib/difuzio/messages
  orphan_grace: 24h
  sweep_batch_size: 500

Cette section décide où vit le brut des messages : le RFC 5322 complet, pièces jointes comprises.

blob_backend: fs (le défaut) le range dans une arborescence de fichiers, et la base ne garde que la ligne messages : métadonnées, empreinte SHA-256 et chemin relatif de l'objet. C'est le mode recommandé pour toute installation. La raison est la forme de la donnée : un corps de message est immuable, écrit une fois, relu quelques fois, et peut peser jusqu'à lmtp.max_message_bytes (50 Mio par défaut). En colonne LONGBLOB, ce corps traverse le binlog à chaque insertion répliquée, ses pages de débordement chassent du cache InnoDB les tables réellement chaudes, et un dump logique devient inexploitable : la taille de la base cesse de dépendre du nombre d'abonnés pour dépendre du volume de pièces jointes qui a transité.

blob_backend: db remet les corps dans la colonne raw_blob. C'est un dernier recours, réservé au cas d'un déploiement multi-hôte où serve-lmtp et les worker tournent sur des machines différentes sans système de fichiers partagé. Le démarrage journalise alors un avertissement, et difuzio doctor le rappelle.

blob_dir doit être un chemin absolu. Le répertoire est créé au démarrage en 0700 et vérifié en écriture : un chemin erroné ou un droit manquant fait échouer le démarrage plutôt que la première réception. Sous systemd, c'est le StateDirectory de l'unité. Le répertoire contient le contenu intégral du courrier des abonnés : il ne doit jamais être exposé, et il fait partie du périmètre de sauvegarde au même titre que la base (voir Exploitation).

orphan_grace (minimum 1 h) est l'âge à partir duquel un objet que plus aucune ligne ne référence est récupéré par le balayage périodique. Un tel objet apparaît quand une réception est annulée après l'écriture du fichier : difuzio écrit toujours l'objet avant la ligne qui le désigne, parce que perdre des octets est inacceptable alors que laisser un fichier ne coûte que de l'espace, repris au balayage suivant.

Les deux modes coexistent en lecture : changer blob_backend n'entraîne aucune réécriture, les anciens messages restent lisibles là où ils ont été écrits.

La purge des corps expirés relève du réglage de liste archive_retention_days (non renseigné = conservation illimitée) : elle efface le corps sur disque comme en base et conserve l'index d'archive (auteur, sujet, date). Un message encore nécessaire à un envoi en cours n'est jamais purgé.

Section web

web:
  bind: "127.0.0.1:8080"
  trusted_proxies: ["127.0.0.1/32", "::1/128"]

bind est l'adresse d'écoute de serve-web. On l'expose toujours derrière un reverse proxy TLS (Proxy web frontal). trusted_proxies liste les réseaux (CIDR ou IP simple) autorisés à parler au nom du client : quand la connexion TCP vient d'une de ces adresses, l'IP cliente consignée (audit du login notamment) est résolue depuis X-Forwarded-For, en prenant le saut le plus à droite hors de la liste -- un préfixe forgé par le client ne peut donc jamais gagner. Le défaut couvre un proxy sur la même machine (loopback v4 et v6) ; ajouter l'adresse du proxy s'il est distant, ou mettre [] pour désactiver toute confiance. Une entrée invalide est fatale au démarrage.

Section lmtp

lmtp:
  socket: "/run/difuzio/lmtp.sock"
  max_message_bytes: 52428800   # 50 Mio
  max_recipients: 100           # == Postfix lmtp_destination_recipient_limit
  max_concurrent: 200
  read_timeout: 5m
  write_timeout: 5m

serve-lmtp écoute sur une socket Unix (ou en TCP si socket est vide et bind_tcp renseigné). max_recipients doit correspondre au lmtp_destination_recipient_limit de Postfix. La socket est créée en mode 0660.

Section relay

relay:
  host: "127.0.0.1"
  port: 587
  starttls: true
  smtp_timeout: 30s
  messages_per_minute: 600
  per_domain_messages_per_minute: 300
  default_pool: "main"
  pools:
    main:
      selector_mode: header     # header|verp_subdomain
      per_pool_messages_per_minute: 600

C'est le relais submission par lequel worker réinjecte le courrier sortant. default_pool est obligatoire dès qu'un domaine émet. Chaque pool nommé permet d'isoler la réputation : selector_mode: header ajoute un en-tête X-Difuzio-Pool (à mapper côté Postfix vers une IP source), verp_subdomain utilise un sous-domaine VERP. Voir Délivrabilité.

Authentification SMTP (relay.auth, relay.auths, relay.envelope_from)

relay:
  auth:                             # identité par défaut (profil boite externe)
    mechanism: plain                # plain|login
    username: "robot@lists.example.org"
    password_file: "/etc/difuzio/secrets/smtp-robot"    # 0600, mot de passe verbatim
  auths:                            # comptes dédiés par liste (optionnel)
    membres@lists.example.org:
      mechanism: plain
      username: "membres@lists.example.org"
      password_file: "/etc/difuzio/secrets/smtp-membres"
  envelope_from: verp               # verp|auth (défaut verp)

Sans bloc auth, le relais soumet à un Postfix de confiance sans authentification (profil classique). Avec un bloc auth, le worker s'authentifie auprès du submission de l'hébergeur (profil boite externe) ; relay.starttls: true devient obligatoire et le mot de passe est lu au démarrage depuis un fichier 0600.

relay.auths sert les hébergeurs (OVH notamment) qui exigent que l'identité SMTP AUTH corresponde au From du message : chaque entrée, indexée par l'adresse de post de la liste, donne à cette liste son propre compte d'envoi. Les messages de la liste s'authentifient alors avec ce compte ; tout le reste (mails de service du robot, listes sans compte dédié) retombe sur relay.auth, qui devient obligatoire dès que relay.auths est présent. Les clés sont normalisées en minuscules (domaine en A-label) au chargement.

relay.envelope_from: auth remplace l'enveloppe MAIL FROM (normalement l'adresse de retour VERP) par le compte authentifié de chaque copie. C'est nécessaire chez les hébergeurs qui refusent une enveloppe n'appartenant pas au compte ; en contrepartie, l'attribution VERP des rebonds est perdue (assumé dans le profil boite externe, qui ne traite pas les rebonds automatiquement). envelope_from: auth exige un bloc relay.auth.

Section worker

worker:
  concurrency: 4
  reclaim_seconds: 600          # >= 2x relay.smtp_timeout (fatal sinon)
  max_attempts: 12
  outq_retention_days: 3
  inbound_retention_days: 7     # >= 7 (dedup LMTP vs file MTA amont)

concurrency est le nombre de tâches d'envoi traitées en parallèle par worker. reclaim_seconds est le délai après lequel une tâche prise par un worker mort est réattribuée ; l'invariant reclaim_seconds >= 2 x smtp_timeout est vérifié au démarrage. max_attempts borne les tentatives sur verdict de remise réel. inbound_retention_days doit valoir au moins 7 (la déduplication LMTP doit couvrir la durée de rétention de la file MTA amont).

Section bounce

bounce:
  window_days: 30
  threshold: 100
  verp_max_age_days: 90

Fenêtre de calcul du score de rebond, seuil de désactivation, et âge maximal d'un token VERP accepté en retour. Voir Délivrabilité.

Section reputation

reputation:
  window_days: 7
  min_sent: 500
  complaint_rate_warn: 0.001
  complaint_rate_suspend: 0.003
  hard_bounce_rate_suspend: 0.05

Paramètres du kill-switch de réputation par liste. En deçà de min_sent messages sur la fenêtre, aucune suspension n'est déclenchée (plancher anti-bruit). Au-delà, un taux de plaintes supérieur à complaint_rate_suspend (0,3 %) ou un taux de rebonds durs supérieur à hard_bounce_rate_suspend (5 %) suspend l'envoi de la liste. Voir Modération.

Section metrics

metrics:
  bind: "127.0.0.1:9090"        # /metrics sur un port séparé (vide = désactivé)

Expose /metrics (format Prometheus) sur un port distinct du trafic applicatif. Laisser vide pour désactiver. En multi-workers, cette valeur est surchargée par instance dans le template systemd (DIFUZIO_METRICS__BIND=127.0.0.1:909%i, donc 9091 pour worker@1, 9092 pour worker@2...) : un port statique partagé ferait échouer le bind de toutes les instances sauf la première. Voir Exploitation.

Section security (keyrings)

security:
  session_keyring:
    current: 1
    keys:
      - { version: 1, file: "${CREDENTIALS_DIRECTORY}/session.key" }
  token_keyring:
    current: 1
    keys:
      - { version: 1, file: "${CREDENTIALS_DIRECTORY}/token.key" }
  verp_keyring:
    current: 1
    keys:
      - { version: 1, file: "${CREDENTIALS_DIRECTORY}/verp.key" }
  # Optionnel, voir plus bas.
  credential_keyring:
    current: 1
    keys:
      - { version: 1, file: "${CREDENTIALS_DIRECTORY}/credential.key" }

Trois keyrings séparés par durée de vie, plus un quatrième optionnel. Chaque clé est lue depuis un fichier en mode 0600, généralement exposé par systemd via LoadCredential= ; les systemd récents déposent cette copie en 0440 root:<groupe du service> sous $CREDENTIALS_DIRECTORY, et ce bit de lecture groupe est toléré là, et seulement là. Les valeurs sont des secrets base64 d'au moins 32 octets. Le démarrage est fatal si un keyring requis manque. Le champ current désigne la version de clé utilisée pour signer ; les autres versions déclarées restent acceptées en vérification (rotation sans rupture).

Le chemin d'une clé peut référencer des variables d'environnement : systemd donne à chaque unité le chemin de ses propres credentials dans $CREDENTIALS_DIRECTORY, si bien que la même configuration sert serve-web, serve-lmtp et worker. Une variable non définie est une erreur explicite, jamais un chemin vide. Hors systemd, poser la variable sur la commande : CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio config check --config .... Un chemin absolu en clair reste accepté (voir config.example-external.yaml) ; le compte de service doit alors pouvoir lire le fichier. Génération, permissions et rotation : Installation, section 2.

Une clé peut aussi être fournie en ligne (value: "<base64>") au lieu de file:, mais pas les deux pour la même version : c'est réservé aux environnements de test, le secret se retrouvant dans le fichier de configuration.

credential_keyring (optionnel)

Ce quatrième keyring ne signe rien : il chiffre les mots de passe des comptes de messagerie saisis par liste dans la console (profil boîte externe, voir Listes). Il n'est requis que si de tels comptes sont utilisés ; une installation avec son propre serveur mail n'en a pas besoin et démarre sans.

  • Absent, la console refuse d'enregistrer un compte en le disant explicitement, plutôt que d'écrire un mot de passe en clair.
  • Déclaré puis retiré alors que des comptes existent, le démarrage échoue en annonçant combien de comptes sont devenus illisibles : sans la clé, les listes concernées seraient muettes sans que rien ne le signale.
  • Sa perte n'est pas rattrapable : les mots de passe doivent être ressaisis. Il se sauvegarde avec les trois autres.
  • Rotation : ajouter une version, la désigner current, garder l'ancienne dans keys le temps que les comptes soient rescellés (ce qui arrive à leur prochaine modification).

Section policy

policy:
  external_mailboxes: allow      # allow (défaut) | deny

Profils de déploiement que cette installation accepte. Cette section existe parce que deux personnes différentes décident : l'exploitant possède le fichier de configuration et la machine, l'administrateur de domaine possède la console. Ce que le premier interdit ici, le second ne peut plus faire.

external_mailboxes

Gouverne le profil boîte externe dans son ensemble (voir Comptes de messagerie externes). Avec deny :

  • aucun compte IMAP/SMTP de fournisseur ne peut être enregistré, ni depuis la console, ni par l'API (403 external_mailboxes_disabled), ni par difuzio credential import ;
  • la console cesse d'afficher la section "Comptes de messagerie du fournisseur" sur les fiches de liste, plutôt que de proposer un formulaire toujours refusé ;
  • ingest.enabled: true et relay.auths deviennent des erreurs de configuration au démarrage, nommées telles quelles : une contradiction entre la politique et le profil qu'elle interdit n'est jamais tranchée en silence ;
  • les comptes déjà enregistrés ne sont plus utilisés : ni relevé IMAP, ni authentification par liste.

relay.auth (identité unique du relais authentifié) reste autorisé : un smarthost authentifié est un réglage de relais ordinaire choisi par l'exploitant, pas une boîte par liste qu'un administrateur peut pointer où il veut.

Basculer sur deny alors que des comptes sont encore enregistrés est une erreur FATALE au démarrage de serve-web et de worker, qui nomme chaque liste concernée. C'est délibéré : ces listes deviendraient muettes (boîte jamais relevée, envoi retombant sur l'identité par défaut) sans que rien ne l'indique. Les supprimer d'abord :

difuzio credential ls --domain lists.example.org --list membres
difuzio credential rm --domain lists.example.org --list membres --kind ingest

difuzio doctor rend le même verdict avant le redémarrage, et affiche credentials: disabled by policy quand la situation est saine.

Le défaut est allow : une configuration écrite avant l'existence de cette section garde exactement son comportement.

Section domains

domains:
  - name: "lists.example.org"
    base_url: "https://lists.example.org"

Liste des domaines connus au démarrage. base_url est obligatoire : tous les liens insérés dans les courriers (confirmation, désabonnement, archives) pointent vers cette URL. Un domaine sans base_url fait échouer le démarrage. On peut aussi provisionner des domaines à chaud via difuzio add-domain (voir Domaines).