Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,920 @@
// ============================================================
// SONDE — CONFIGURATION SMTP RÉELLEMENT SERVIE À UN COLLABORATEUR
//
// Exécute le VRAI service de production (SmtpConfigurationService + repositories
// Omika + déchiffreur sMdpStandard) contre une base Omika, et affiche pour chaque
// collaborateur demandé :
// 1. les lignes physiques lues en base (contexte) ;
// 2. la liste des configurations EXPOSÉES AU MOBILE (contrat
// GET /api/mails/configurations-smtp : Id, Libelle, Email) ;
// 3. la configuration EFFECTIVE utilisée à l'envoi (serveur, port, cryptage,
// login, mot de passe résolu, signature, CCI) et sa validité.
//
// LECTURE SEULE : aucune écriture, aucune connexion SMTP, aucun envoi.
// Le mot de passe n'est jamais affiché en clair sans --montrer-mdp.
//
// USAGE : Omika.SmtpMobileConfigProbe [id...] [--montrer-mdp]
// ============================================================
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Omika.Core.Configuration;
using Omika.Core.Contracts.Documents;
using Omika.Core.Interfaces;
using Omika.Core.Security;
using Omika.Core.Services;
using Omika.Core.Services.Mobile;
using Omika.Data.Configuration;
using Omika.Data.Connection;
using Omika.Data.HfSql.Connection;
using Omika.Data.Repositories.Omika;
Console.OutputEncoding = Encoding.UTF8;
const string CheminConfiguration = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
bool montrerMotDePasse = args.Contains("--montrer-mdp", StringComparer.OrdinalIgnoreCase);
bool montrerSignatures = args.Contains("--signatures", StringComparer.OrdinalIgnoreCase);
string dossierSignatures = Path.Combine(Directory.GetCurrentDirectory(), ".diag", "signatures");
string[] cibles = args
.Where(a => !a.StartsWith("--", StringComparison.Ordinal))
.ToArray();
if (montrerSignatures)
{
Directory.CreateDirectory(dossierSignatures);
}
if (cibles.Length == 0)
{
cibles = ["281474976710679", "281474976710671", "281474976710697"];
}
// --- 1. Options HFSQL de la base de test (section « Hfsql ») -----------------
if (!File.Exists(CheminConfiguration))
{
Console.Error.WriteLine($"ERREUR : configuration absente ({CheminConfiguration}).");
return 2;
}
HfsqlOptions options;
using (JsonDocument document = JsonDocument.Parse(File.ReadAllText(CheminConfiguration)))
{
JsonElement hfsql = document.RootElement.GetProperty("Hfsql");
JsonElement motsDePasse = hfsql.GetProperty("FilePasswords");
options = new HfsqlOptions
{
ConnectionName = LireTexte(hfsql, "ConnectionName") ?? "Business",
Server = LireTexte(hfsql, "Server"),
Port = hfsql.TryGetProperty("Port", out JsonElement port) ? port.GetInt32() : 4900,
DatabaseName = LireTexte(hfsql, "DatabaseName"),
Login = LireTexte(hfsql, "Login"),
Password = LireTexte(hfsql, "Password"),
Driver = LireTexte(hfsql, "Driver") ?? HfsqlOptions.DefaultOdbcDriver,
FilePasswords = new HfSqlFilePasswordOptions
{
ProtectionStd = LireTexte(motsDePasse, "ProtectionStd"),
ProtectionPlus = LireTexte(motsDePasse, "ProtectionPlus")
}
};
}
Console.WriteLine("==============================================================================");
Console.WriteLine(" CONFIGURATION SMTP SERVIE AU MOBILE — SERVICE DE PRODUCTION, LECTURE SEULE");
Console.WriteLine("==============================================================================");
Console.WriteLine($"Base : {options.DatabaseName} ({options.Server}:{options.Port})");
Console.WriteLine($"Identifiants : {string.Join(", ", cibles)} (lus d'abord comme UT_PKID, puis CO_PKID)");
Console.WriteLine($"Mdp en clair : {(montrerMotDePasse ? "OUI (--montrer-mdp)" : "non (longueur seulement)")}");
Console.WriteLine();
// --- 2. Service de production, câblé sur les vrais repositories Omika --------
await using var connexion = new BusinessOleDbConnection(options, new HfSqlOleDbConnectionFactory());
await connexion.OpenAsync();
var service = new SmtpConfigurationService(
new OmikaSmtpConfigurationRepository(connexion),
new OmikaCollaborateurRepository(connexion),
new MobileEntityIdentityResolver(),
new JournalConsole(),
Options.Create(new SmtpPasswordOptions()),
new SmtpPasswordStandardDecryptor());
foreach (string id in cibles)
{
Console.WriteLine("==============================================================================");
Console.WriteLine($" IDENTIFIANT FOURNI : {id}");
Console.WriteLine("==============================================================================");
try
{
(string collaborateurId, string origine) = await ResoudreCollaborateurAsync(connexion, id);
Console.WriteLine("-- 0. Résolution de l'identifiant");
Console.WriteLine($" {origine}");
if (collaborateurId.Length == 0)
{
Console.WriteLine(" → aucun collaborateur résolu : le mobile ne reçoit RIEN.");
Console.WriteLine();
continue;
}
await AfficherContexteAsync(connexion, collaborateurId);
IReadOnlyList<SmtpConfigurationDto> liste = await service.GetListAsync(collaborateurId);
Console.WriteLine("-- CE QUE LE MOBILE REÇOIT (GET /api/mails/configurations-smtp)");
if (liste.Count == 0)
{
Console.WriteLine(" (aucune configuration : l'écran de sélection sera vide)");
}
foreach (SmtpConfigurationDto dto in liste)
{
string libelle = string.IsNullOrWhiteSpace(dto.Libelle)
? (dto.Email ?? "(sans libellé)")
: dto.Libelle;
Console.WriteLine($" - Id = {dto.Id}");
Console.WriteLine($" Libelle = {libelle}");
Console.WriteLine($" Email = {dto.Email ?? "(aucun)"}");
}
Console.WriteLine();
Console.WriteLine("-- CONFIGURATION EFFECTIVE UTILISÉE À L'ENVOI (résolue côté serveur)");
if (liste.Count == 0)
{
Console.WriteLine(" (aucune configuration à résoudre)");
}
foreach (SmtpConfigurationDto dto in liste)
{
SmtpConfiguration? configuration = await service.GetAsync(collaborateurId, dto.Id);
if (configuration is null)
{
Console.WriteLine($" [{dto.Id}] NON RÉSOLUE (identifiant absent de la liste chargée).");
continue;
}
AfficherConfiguration(configuration, montrerMotDePasse, montrerSignatures, dossierSignatures, id);
}
}
catch (Exception ex)
{
Console.WriteLine($" ERREUR {ex.GetType().Name} : {ex.Message}");
}
Console.WriteLine();
}
Console.WriteLine("Sonde terminée (aucune modification en base).");
// --- Preuve du cas « configuration de SOCIÉTÉ seule » -----------------------
// Le bug d'origine : un collaborateur en messagerie interne qui n'a AUCUNE ligne
// EMAIL_ENVOI personnelle ne recevait rien (ou une configuration fantôme). On
// cherche ici de vrais collaborateurs dans cette situation et on affiche ce que
// le service leur rend.
if (args.Contains("--exemples-societe", StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine();
Console.WriteLine("==============================================================================");
Console.WriteLine(" EXEMPLES : messagerie « Back-Office » SANS configuration personnelle");
Console.WriteLine("==============================================================================");
IReadOnlyList<IReadOnlyDictionary<string, object?>> candidats = await connexion.QueryAsync(
"SELECT PU_IDUtilisateur FROM ParametreUtilisateur " +
"WHERE PU_Code = 'CHOIX_MESSAGERIE' AND PU_Valeur = 'Back-Office'",
null);
int montres = 0;
foreach (IReadOnlyDictionary<string, object?> candidat in candidats)
{
if (!candidat.TryGetValue("PU_IDUtilisateur", out object? brutId) || brutId is null
|| !long.TryParse(brutId.ToString(), out long utid))
{
continue;
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> perso = await connexion.QueryAsync(
"SELECT PU_Code FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ? AND PU_Code LIKE 'EMAIL_ENVOI%'",
[utid]);
if (perso.Count > 0)
{
continue;
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> users = await connexion.QueryAsync(
"SELECT UT_PKID, UT_Login, UT_IDCollaborateur FROM Utilisateur WHERE UT_PKID = ?",
[utid]);
if (users.Count == 0
|| !users[0].TryGetValue("UT_IDCollaborateur", out object? brutColb) || brutColb is null
|| !long.TryParse(brutColb.ToString(), out long colbId) || colbId <= 0)
{
continue;
}
string login = users[0].TryGetValue("UT_Login", out object? brutLogin)
? brutLogin?.ToString() ?? "?"
: "?";
Console.WriteLine();
Console.WriteLine($"-- Collaborateur {colbId} (utilisateur {utid}, login {login}) — aucune ligne EMAIL_ENVOI");
IReadOnlyList<SmtpConfigurationDto> liste = await service.GetListAsync(colbId.ToString());
foreach (SmtpConfigurationDto dto in liste)
{
Console.WriteLine($" reçu : Id = {dto.Id} | Libelle = {dto.Libelle} | Email = {dto.Email ?? "(aucun)"}");
SmtpConfiguration? configuration = await service.GetAsync(colbId.ToString(), dto.Id);
if (configuration is not null)
{
AfficherConfiguration(
configuration, montrerMotDePasse, montrerSignatures, dossierSignatures, colbId.ToString());
}
}
if (liste.Count == 0)
{
Console.WriteLine(" reçu : liste VIDE");
}
montres++;
if (montres >= 3)
{
break;
}
}
if (montres == 0)
{
Console.WriteLine(" (aucun collaborateur dans cette situation dans la base)");
}
}
// --- Audit : la fusion société + ligne utilisateur perd-elle un champ ? ------
// Un « Configuration SMTP invalide ou incomplète » ne peut venir que d'une
// configuration EFFECTIVE sans serveur, port, cryptage, login ou mot de passe.
// La fusion (Fusionner) ne reprend de la ligne EMAIL_ENVOI_<soc> que le compte,
// la signature et les copies : si la SOCIÉTÉ n'a pas de login ou de mot de passe
// alors que la ligne utilisateur les porte, l'envoi échoue. On mesure ce cas.
if (args.Contains("--audit-fusion", StringComparer.OrdinalIgnoreCase))
{
await AuditerFusionAsync(connexion);
}
return 0;
// ============================================================
// Audit de la fusion société / ligne utilisateur
// ============================================================
static async Task AuditerFusionAsync(IHfsqlConnection connexion)
{
Console.WriteLine();
Console.WriteLine("==============================================================================");
Console.WriteLine(" AUDIT — PRÉSENCE DES CHAMPS SMTP (sociétés vs lignes utilisateur)");
Console.WriteLine("==============================================================================");
Dictionary<long, PresenceSmtp> societes = new();
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignesSociete = await connexion.QueryAsync(
"SELECT SO_PKID, SO_Parametres FROM Societe",
null);
foreach (IReadOnlyDictionary<string, object?> ligne in lignesSociete)
{
if (!ligne.TryGetValue("SO_PKID", out object? brut) || brut is null
|| !long.TryParse(brut.ToString(), out long soId) || soId <= 0)
{
continue;
}
string? json = ligne.TryGetValue("SO_Parametres", out object? brutJson) ? brutJson?.ToString() : null;
PresenceSmtp presence = LirePresence(json);
societes[soId] = presence;
Console.WriteLine($" Société {soId,-20} {presence.Resume()}");
}
Console.WriteLine();
Console.WriteLine("-- C. CAS OÙ LA FUSION ACTUELLE PERD LE LOGIN OU LE MOT DE PASSE");
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignesUtilisateur = await connexion.QueryAsync(
"SELECT PU_IDUtilisateur, PU_Code, PU_Valeur FROM ParametreUtilisateur " +
"WHERE PU_Code LIKE 'EMAIL_ENVOI\\_%'",
null);
int cas = 0;
foreach (IReadOnlyDictionary<string, object?> ligne in lignesUtilisateur)
{
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
string suffixe = code["EMAIL_ENVOI_".Length..];
if (!long.TryParse(suffixe, out long soId) || !societes.TryGetValue(soId, out PresenceSmtp societe))
{
continue;
}
string? json = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
PresenceSmtp perso = LirePresence(json);
bool loginPerdu = !societe.Login && perso.Login;
bool mdpPerdu = !societe.MdpStandard && perso.MdpStandard;
bool serveurPerdu = (!societe.Serveur || !societe.Port || !societe.Cryptage)
&& perso.Serveur && perso.Port && perso.Cryptage;
if (!loginPerdu && !mdpPerdu && !serveurPerdu)
{
continue;
}
cas++;
string ut = ligne.TryGetValue("PU_IDUtilisateur", out object? brutUt) ? brutUt?.ToString() ?? "?" : "?";
Console.WriteLine($" UT {ut} / {code}");
Console.WriteLine($" société {soId} : {societe.Resume()}");
Console.WriteLine($" ligne utilisateur : {perso.Resume()}");
Console.WriteLine(
$" → perdu par la fusion : " +
string.Join(", ", new[]
{
loginPerdu ? "LOGIN" : null,
mdpPerdu ? "MOT DE PASSE" : null,
serveurPerdu ? "SERVEUR/PORT/CRYPTAGE" : null
}.Where(v => v is not null)));
}
Console.WriteLine();
Console.WriteLine($" {cas} cas où la fusion jette un champ indispensable alors que la ligne utilisateur le porte.");
if (cas == 0)
{
Console.WriteLine(" (aucun dans cette base : les sociétés portent elles-mêmes login et mot de passe)");
}
}
static PresenceSmtp LirePresence(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
JsonElement racine = document.RootElement;
if (racine.ValueKind == JsonValueKind.Array)
{
if (racine.GetArrayLength() == 0)
{
return default;
}
racine = racine[0];
}
if (racine.ValueKind != JsonValueKind.Object)
{
return default;
}
JsonElement compte = racine.TryGetProperty("Compte", out JsonElement blocCompte)
&& blocCompte.ValueKind == JsonValueKind.Object ? blocCompte : default;
JsonElement smtp = racine.TryGetProperty("SMTP", out JsonElement blocSmtp)
&& blocSmtp.ValueKind == JsonValueKind.Object ? blocSmtp : default;
bool cryptage = smtp.ValueKind == JsonValueKind.Object
&& smtp.TryGetProperty("nCryptage", out JsonElement n)
&& n.ValueKind == JsonValueKind.Number
&& n.GetInt32() is 1 or 2 or 3;
return new PresenceSmtp(
Present(smtp, "sAdresseServeur"),
Present(smtp, "sPortServeur"),
cryptage,
Present(smtp, "sLogin"),
Present(smtp, "sMdpStandard"),
Present(smtp, "sMdp"),
Present(compte, "sCompte"),
Present(compte, "sEmail"));
}
catch (JsonException)
{
return default;
}
}
static bool Present(JsonElement element, string nom)
=> element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty(nom, out JsonElement valeur)
&& valeur.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(valeur.GetString());
// ============================================================
// Affichage
// ============================================================
/// <summary>
/// Résout un identifiant fourni vers l'identifiant de COLLABORATEUR attendu par
/// le service. Un identifiant utilisateur (<c>UT_PKID</c>) est l'entrée la plus
/// fréquente ; un <c>CO_PKID</c> reste accepté (et signalé comme tel).
/// </summary>
static async Task<(string CollaborateurId, string Origine)> ResoudreCollaborateurAsync(
IHfsqlConnection connexion,
string id)
{
if (!long.TryParse(id, out long cle) || cle <= 0)
{
return (string.Empty, "Identifiant non numérique : le mobile n'envoie jamais un tel identifiant.");
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> utilisateurs = await connexion.QueryAsync(
"SELECT UT_PKID, UT_Login, UT_Nom, UT_Prenom, UT_IDCollaborateur FROM Utilisateur WHERE UT_PKID = ?",
[cle]);
if (utilisateurs.Count > 0)
{
IReadOnlyDictionary<string, object?> utilisateur = utilisateurs[0];
string login = utilisateur.TryGetValue("UT_Login", out object? brutLogin)
? brutLogin?.ToString() ?? "?"
: "?";
string nom = utilisateur.TryGetValue("UT_Nom", out object? brutNom) ? brutNom?.ToString() ?? "" : "";
string prenom = utilisateur.TryGetValue("UT_Prenom", out object? brutPrenom) ? brutPrenom?.ToString() ?? "" : "";
bool lie = utilisateur.TryGetValue("UT_IDCollaborateur", out object? brutColb)
&& brutColb is not null
&& long.TryParse(brutColb.ToString(), out long colbId)
&& colbId > 0;
if (!lie)
{
return (string.Empty,
$"UT_PKID {cle} = utilisateur « {login} » ({nom} {prenom}) mais UT_IDCollaborateur est vide : " +
"aucun collaborateur, donc aucune configuration.");
}
long collaborateurId = long.Parse(utilisateur["UT_IDCollaborateur"]!.ToString()!);
return (collaborateurId.ToString(),
$"UT_PKID {cle} = utilisateur « {login} » ({nom} {prenom}) → collaborateur {collaborateurId}");
}
IReadOnlyList<IReadOnlyDictionary<string, object?>> collaborateurs = await connexion.QueryAsync(
"SELECT CO_PKID, CO_Nom, CO_Prenom FROM Collaborateur WHERE CO_PKID = ?",
[cle]);
if (collaborateurs.Count > 0)
{
IReadOnlyDictionary<string, object?> collaborateur = collaborateurs[0];
string nom = collaborateur.TryGetValue("CO_Nom", out object? brutNom) ? brutNom?.ToString() ?? "" : "";
string prenom = collaborateur.TryGetValue("CO_Prenom", out object? brutPrenom) ? brutPrenom?.ToString() ?? "" : "";
return (id, $"Aucun UT_PKID {cle} : c'est un CO_PKID ({nom} {prenom}), utilisé directement.");
}
return (string.Empty,
$"Identifiant {cle} absent de Utilisateur (UT_PKID) ET de Collaborateur (CO_PKID).");
}
static async Task AfficherContexteAsync(IHfsqlConnection connexion, string id)
{
long pk = long.TryParse(id, out long pkNumerique) ? pkNumerique : 0;
Console.WriteLine("-- 1. Collaborateur (CO_PKID)");
IReadOnlyList<IReadOnlyDictionary<string, object?>> collaborateurs = await AfficherLignesAsync(
connexion,
"SELECT CO_PKID, CO_Nom, CO_Prenom, CO_Email, CO_Actif, GUID FROM Collaborateur WHERE CO_PKID = ?",
[pk]);
if (collaborateurs.Count == 0)
{
Console.WriteLine(
" ⚠ Aucun Collaborateur pour cet identifiant : le service rend une liste VIDE " +
"(il n'existe aucun utilisateur lié).");
}
Console.WriteLine("-- 2. Utilisateur lié (UT_IDCollaborateur = CO_PKID)");
IReadOnlyList<IReadOnlyDictionary<string, object?>> utilisateurs = await AfficherLignesAsync(
connexion,
"SELECT UT_PKID, UT_Login, UT_Nom, UT_Prenom, UT_IDCollaborateur, UT_IDSocietePrincipale " +
"FROM Utilisateur WHERE UT_IDCollaborateur = ?",
[pk]);
if (utilisateurs.Count == 0)
{
Console.WriteLine(
" (aucun Utilisateur pour ce collaborateur : le service ne peut résoudre aucun compte d'envoi)");
}
var idsUtilisateur = new List<long>();
foreach (IReadOnlyDictionary<string, object?> ligne in utilisateurs)
{
if (ligne.TryGetValue("UT_PKID", out object? brutUt) && brutUt is not null
&& long.TryParse(brutUt.ToString(), out long utid))
{
idsUtilisateur.Add(utid);
}
}
Console.WriteLine("-- 3. Sociétés de rattachement (Societe_Collaborateur)");
IReadOnlyList<IReadOnlyDictionary<string, object?>> liaisons = await AfficherLignesAsync(
connexion,
"SELECT SC_IDCSociete FROM Societe_Collaborateur WHERE SC_IDCollaborateur = ?",
[pk]);
var idsSociete = new List<long>();
foreach (IReadOnlyDictionary<string, object?> ligne in liaisons)
{
if (ligne.TryGetValue("SC_IDCSociete", out object? brutSoc) && brutSoc is not null
&& long.TryParse(brutSoc.ToString(), out long socId) && socId > 0)
{
idsSociete.Add(socId);
}
}
if (idsSociete.Count > 0)
{
string placeholders = string.Join(", ", idsSociete.Select(_ => "?"));
Console.WriteLine(" Paramètres des sociétés rattachées (Societe.SO_Parametres) :");
IReadOnlyList<IReadOnlyDictionary<string, object?>> societes = await connexion.QueryAsync(
$"SELECT SO_PKID, SO_Parametres FROM Societe WHERE SO_PKID IN ({placeholders})",
idsSociete.Cast<object?>().ToArray());
foreach (IReadOnlyDictionary<string, object?> ligne in societes)
{
string soId = ligne.TryGetValue("SO_PKID", out object? brutSo) ? brutSo?.ToString() ?? "?" : "?";
string? contenu = ligne.TryGetValue("SO_Parametres", out object? brutParams)
? brutParams?.ToString()
: null;
Console.WriteLine($" Société {soId}");
ResumeJson($"EMAIL_ENVOI_{soId}", contenu);
}
}
Console.WriteLine("-- 4. ParametreUtilisateur (EMAIL_ENVOI% + CHOIX_MESSAGERIE)");
if (idsUtilisateur.Count == 0)
{
Console.WriteLine(" (aucun utilisateur : aucune ligne à lire)");
return;
}
foreach (long utid in idsUtilisateur)
{
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignes = await connexion.QueryAsync(
"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ? " +
"AND (PU_Code LIKE 'EMAIL_ENVOI%' OR PU_Code = 'CHOIX_MESSAGERIE')",
[utid]);
if (lignes.Count == 0)
{
Console.WriteLine($" (UT_PKID {utid} : aucune ligne EMAIL_ENVOI / CHOIX_MESSAGERIE)");
}
foreach (IReadOnlyDictionary<string, object?> ligne in lignes)
{
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
string? contenu = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
Console.WriteLine($" UT_PKID {utid} / {code}");
ResumeJson(code, contenu);
}
// Inventaire complet : garantit qu'aucune ligne de configuration SMTP
// n'échappe au filtre du service (autre code, autre convention).
Console.WriteLine($" (toutes les lignes ParametreUtilisateur de UT_PKID {utid})");
IReadOnlyList<IReadOnlyDictionary<string, object?>> toutes = await connexion.QueryAsync(
"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ?",
[utid]);
if (toutes.Count == 0)
{
Console.WriteLine(" (aucune ligne du tout)");
}
foreach (IReadOnlyDictionary<string, object?> ligne in toutes)
{
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
string? contenu = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
string apercu = contenu is null
? "(null)"
: contenu.Length <= 45
? $"« {contenu} » ({contenu.Length} car.)"
: $"{contenu.Length} car.";
Console.WriteLine($" {code} = {apercu}");
}
}
}
static async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> AfficherLignesAsync(
IHfsqlConnection connexion,
string sql,
IReadOnlyList<object?> parametres)
{
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignes =
await connexion.QueryAsync(sql, parametres);
if (lignes.Count == 0)
{
Console.WriteLine(" (aucune ligne)");
return lignes;
}
foreach (IReadOnlyDictionary<string, object?> ligne in lignes)
{
IEnumerable<string> paires = ligne.Select(kv => $"{kv.Key} = {ResumerValeur(kv.Value)}");
Console.WriteLine(" " + string.Join(" | ", paires));
}
return lignes;
}
/// <summary>Résume une valeur JSON de <c>ParametreUtilisateur</c> sans secret.</summary>
static void ResumeJson(string code, string? valeur)
{
if (string.IsNullOrWhiteSpace(valeur))
{
Console.WriteLine(" (valeur vide)");
return;
}
string debut = valeur.TrimStart();
if (!debut.StartsWith('{') && !debut.StartsWith('['))
{
Console.WriteLine(
$" VALEUR NON JSON ({valeur.Length} car.) : ignorée par le service " +
"(chiffrement/binaire historique).");
return;
}
try
{
using JsonDocument document = JsonDocument.Parse(valeur);
JsonElement racine = document.RootElement;
if (racine.ValueKind == JsonValueKind.Array)
{
int index = 0;
foreach (JsonElement element in racine.EnumerateArray())
{
Console.WriteLine($" [tableau, entrée {index}]");
ResumeConfiguration(element);
index++;
}
}
else
{
ResumeConfiguration(racine);
}
}
catch (JsonException)
{
Console.WriteLine($" JSON INVALIDE ({valeur.Length} car.) : ignorée par le service.");
}
}
static void ResumeConfiguration(JsonElement element)
{
string? compte = null;
string? email = null;
if (element.TryGetProperty("Compte", out JsonElement blocCompte)
&& blocCompte.ValueKind == JsonValueKind.Object)
{
compte = LireTexte(blocCompte, "sCompte");
email = LireTexte(blocCompte, "sEmail");
}
if (!element.TryGetProperty("SMTP", out JsonElement smtp) || smtp.ValueKind != JsonValueKind.Object)
{
Console.WriteLine(" (aucun bloc SMTP)");
return;
}
string? login = LireTexte(smtp, "sLogin");
string? serveur = LireTexte(smtp, "sAdresseServeur");
string? port = LireTexte(smtp, "sPortServeur");
string? signature = LireTexte(smtp, "bufSignature");
string? mdp = LireTexte(smtp, "sMdp");
string? mdpStandard = LireTexte(smtp, "sMdpStandard");
int cryptage = smtp.TryGetProperty("nCryptage", out JsonElement n) && n.ValueKind == JsonValueKind.Number
? n.GetInt32()
: 0;
bool enCopieCachee = smtp.TryGetProperty("bEnCopieCachee", out JsonElement bcc)
&& (bcc.ValueKind == JsonValueKind.True
|| (bcc.ValueKind == JsonValueKind.String
&& bool.TryParse(bcc.GetString(), out bool drapeau) && drapeau));
int copies = smtp.TryGetProperty("tabCC_CopieEmail", out JsonElement cc)
&& cc.ValueKind == JsonValueKind.Array
? cc.GetArrayLength()
: 0;
string listeCopies = "(aucune)";
if (copies > 0)
{
var adresses = cc.EnumerateArray()
.Select(a => a.ValueKind == JsonValueKind.String ? (a.GetString() ?? "").Trim() : a.ToString())
.Select(a => a.Length == 0 ? "«vide»" : a);
listeCopies = string.Join(" | ", adresses);
}
Console.WriteLine($" Compte.sCompte = {ResumerValeur(compte)}");
Console.WriteLine($" Compte.sEmail = {ResumerValeur(email)}");
Console.WriteLine($" SMTP.sAdresseServeur = {ResumerValeur(serveur)}");
Console.WriteLine($" SMTP.sPortServeur = {ResumerValeur(port)}");
Console.WriteLine($" SMTP.nCryptage = {LibelleCryptage(cryptage)}");
Console.WriteLine($" SMTP.sLogin = {ResumerValeur(login)}");
Console.WriteLine($" SMTP.sMdp = {(string.IsNullOrWhiteSpace(mdp) ? "absent" : $"présent ({mdp.Length} car.) — JAMAIS lu par Omika")}");
Console.WriteLine($" SMTP.sMdpStandard = {(string.IsNullOrWhiteSpace(mdpStandard) ? "ABSENT" : $"présent ({mdpStandard.Length} car. base64)")}");
Console.WriteLine($" Signature = {(string.IsNullOrWhiteSpace(signature) ? "aucune" : $"{signature.Length} car. base64")}");
Console.WriteLine($" bEnCopieCachee = {enCopieCachee}");
Console.WriteLine($" tabCC_CopieEmail = {copies} adresse(s) — {listeCopies}");
}
static void AfficherConfiguration(
SmtpConfiguration configuration,
bool montrerMotDePasse,
bool montrerSignatures,
string dossierSignatures,
string etiquetteProprietaire)
{
Console.WriteLine();
Console.WriteLine($" [{configuration.Id}]");
string? motDePasse = configuration.MotDePasse;
string statutMotDePasse = string.IsNullOrWhiteSpace(motDePasse)
? "ABSENT → envoi impossible"
: montrerMotDePasse
? $"« {motDePasse} » ({motDePasse.Length} car.)"
: $"{motDePasse.Length} caractère(s), résolu et déchiffré";
Console.WriteLine($" Serveur = {Valeur(configuration.Serveur)}");
Console.WriteLine($" Port = {configuration.Port}");
Console.WriteLine($" Cryptage = {LibelleCryptage(configuration.Cryptage)}");
Console.WriteLine($" Login = {Valeur(configuration.Login)}");
Console.WriteLine($" Expéditeur (From) = {Valeur(configuration.Email)}");
Console.WriteLine($" Compte (libellé) = {Valeur(configuration.Compte)}");
Console.WriteLine($" Mot de passe = {statutMotDePasse}");
Console.WriteLine($" Signature = {(string.IsNullOrWhiteSpace(configuration.Signature) ? "aucune" : $"{configuration.Signature.Length} caractère(s) de HTML")}");
Console.WriteLine($" Copie cachée (CCI) = {(configuration.EnCopieCachee ? "oui" : "non")}");
Console.WriteLine($" Copies (CC) = {(configuration.CcCopieEmail.Count == 0 ? "aucune" : string.Join(", ", configuration.CcCopieEmail))}");
try
{
configuration.Validate();
Console.WriteLine(" Prête à envoyer = OUI (validation complète)");
}
catch (EmailEnvoiException ex)
{
Console.WriteLine($" Prête à envoyer = NON ({ex.Message})");
}
if (montrerSignatures)
{
AfficherSignature(configuration, dossierSignatures, etiquetteProprietaire);
}
}
/// <summary>
/// Affiche la signature qui sera réellement concaténée au corps du courriel :
/// texte visible (balises retirées, images signalées) et fichier HTML complet
/// écrit sur disque pour la vérification visuelle.
/// </summary>
static void AfficherSignature(SmtpConfiguration configuration, string dossier, string etiquettes)
{
Console.WriteLine(" --- SIGNATURE AJOUTÉE AU CORPS ---");
string? html = configuration.Signature;
if (string.IsNullOrWhiteSpace(html))
{
Console.WriteLine(" (aucune signature : le corps du courriel reste inchangé)");
return;
}
int images = Regex.Matches(html, "<img\\b", RegexOptions.IgnoreCase).Count;
int imagesIntegrees = Regex.Matches(html, "src\\s*=\\s*[\"']data:", RegexOptions.IgnoreCase).Count;
int liens = Regex.Matches(html, "<a\\b", RegexOptions.IgnoreCase).Count;
int tableaux = Regex.Matches(html, "<table\\b", RegexOptions.IgnoreCase).Count;
Console.WriteLine($" HTML = {html.Length} caractère(s), {images} image(s) " +
$"({imagesIntegrees} intégrée(s) en base64), {liens} lien(s), {tableaux} tableau(x)");
string texte = SignatureVersTexte(html);
Console.WriteLine(" Texte visible :");
foreach (string ligne in texte.Split('\n'))
{
Console.WriteLine($" | {ligne}");
}
string nomFichier = Regex.Replace($"{etiquettes}_{configuration.Id}", "[^A-Za-z0-9_.-]", "_");
string chemin = Path.Combine(dossier, nomFichier + ".html");
File.WriteAllText(
chemin,
$"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Signature {configuration.Id}</title></head>" +
$"<body><p>--- corps du courriel ---</p>{html}</body></html>",
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
Console.WriteLine($" Aperçu HTML complet : {chemin}");
}
/// <summary>Rend lisible un fragment HTML de signature : texte, images signalées.</summary>
static string SignatureVersTexte(string html)
{
string travail = html;
travail = Regex.Replace(travail, "<!--.*?-->", " ", RegexOptions.Singleline);
travail = Regex.Replace(travail, "<(script|style|head)\\b[^>]*>.*?</\\1\\s*>", " ", RegexOptions.IgnoreCase | RegexOptions.Singleline);
// Images : on signale leur présence sans déverser le base64 dans la console.
travail = Regex.Replace(
travail,
"<img\\b[^>]*?alt\\s*=\\s*[\"']([^\"']*)[\"'][^>]*>",
" [image: $1] ",
RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "<img\\b[^>]*>", " [image] ", RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "<\\s*br\\s*/?>", "\n", RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "</\\s*(p|div|tr|h[1-6]|li|table|blockquote)\\s*>", "\n", RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "<\\s*(p|div|tr|h[1-6]|li|table|blockquote)\\b[^>]*>", "\n", RegexOptions.IgnoreCase);
travail = Regex.Replace(travail, "<[^>]+>", " ");
travail = WebUtility.HtmlDecode(travail);
travail = travail.Replace('\u00a0', ' ');
// Jetons base64 résiduels (attributs data:/cid: collés, styles volumineux).
travail = Regex.Replace(travail, "[A-Za-z0-9+/]{120,}={0,2}", " ");
travail = Regex.Replace(travail, "[ \\t\\r]+", " ");
travail = Regex.Replace(travail, " *\n *", "\n");
travail = Regex.Replace(travail, "\n{2,}", "\n");
return travail.Trim();
}
static string LibelleCryptage(int cryptage) => cryptage switch
{
1 => "1 = aucun",
2 => "2 = TLS / STARTTLS",
3 => "3 = SSL",
_ => $"{cryptage} = NON RENSEIGNÉ"
};
static string Valeur(string? texte)
=> string.IsNullOrWhiteSpace(texte) ? "(absent)" : texte;
static string ResumerValeur(object? valeur)
{
if (valeur is null or DBNull)
{
return "(null)";
}
string texte = valeur.ToString() ?? "";
if (texte.Length <= 90)
{
return string.IsNullOrWhiteSpace(texte) ? "(vide)" : texte;
}
return $"{texte[..90]}… ({texte.Length} car.)";
}
static string? LireTexte(JsonElement element, string nom)
=> element.TryGetProperty(nom, out JsonElement valeur) && valeur.ValueKind == JsonValueKind.String
? valeur.GetString()
: null;
/// <summary>
/// Présence (et non valeur) des champs qui décident de la validité d'une
/// configuration SMTP, telle qu'elle est réellement stockée en base.
/// </summary>
internal readonly record struct PresenceSmtp(
bool Serveur,
bool Port,
bool Cryptage,
bool Login,
bool MdpStandard,
bool MdpLegacy,
bool Compte,
bool Email)
{
public string Resume()
=> $"serveur={Drapeau(Serveur)} port={Drapeau(Port)} cryptage={Drapeau(Cryptage)} " +
$"login={Drapeau(Login)} sMdpStandard={Drapeau(MdpStandard)} sMdpLegacy={Drapeau(MdpLegacy)} " +
$"compte={Drapeau(Compte)} email={Drapeau(Email)}";
private static string Drapeau(bool present) => present ? "oui" : "NON";
}
/// <summary>
/// Journal minimal : les messages du service (invariants de diagnostic) sont
/// affichés tels quels, sans exposer de secret.
/// </summary>
internal sealed class JournalConsole : ILogger<SmtpConfigurationService>
{
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
=> null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
Console.WriteLine($" [journal {logLevel}] {formatter(state, exception)}");
}
}