using ClosedXML; using Google.Protobuf.WellKnownTypes; using MailKit; using MailKit.Net.Imap; using MailKit.Search; using MailKit.Security; using MimeKit; using Org.BouncyCastle.Security; using Services.ReActAgent; using System.Text.Json; using ToolsServices; namespace Services { #region Classe static annexe JsonDb public static class JsonDb { static string FullFileName = FichiersInternesService.ListeMailsSend; public static ThreadStore Load() { if (!File.Exists(FullFileName)) return new ThreadStore(); var json = File.ReadAllText(FullFileName); return JsonSerializer.Deserialize(json) ?? new ThreadStore(); } public static bool Save(ThreadItem thread) { ThreadStore data; // Charger fichier existant if (File.Exists(FullFileName)) { string json = File.ReadAllText(FullFileName); data = JsonSerializer.Deserialize(json, new JsonSerializerOptions { WriteIndented = true }) ?? new ThreadStore(); } else { data = new ThreadStore(); } // Chercher si le thread existe déjà var existing = data.Threads.FirstOrDefault(t => t.Id == thread.Id); if (existing != null) { int index = data.Threads.IndexOf(existing); data.Threads[index] = thread; // remplacement } else { data.Threads.Add(thread); // ajout } // Sauvegarder string updatedJson = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(FullFileName, updatedJson); return true; } public static void Save(ThreadStore store) { Directory.CreateDirectory(Path.GetDirectoryName(FullFileName)!); var json = JsonSerializer.Serialize(store, new JsonSerializerOptions { WriteIndented = true }); var tmp = FullFileName + ".tmp"; File.WriteAllText(tmp, json); File.Move(tmp, FullFileName, true); } } #endregion #region Classe static EmailSendService public static class EmailSendService { #region Variables private static ReActAgent.ReActAgent _ReActAgent = new(); private static List ListInboxFolders = new List(); private static CompteMailUser ParametreUser = new(); #endregion #region Méthodes publiques public static int NbRelancesEnAttente() { var lstReponsesEnAttente = LstRelancesEnAttente(); return lstReponsesEnAttente.Count; } public static ThreadStore ChargerDonnees() { var fileMailsSend = FichiersInternesService.ListeMailsSend; ThreadStore store = new ThreadStore(); if (File.Exists(fileMailsSend)) { var json = System.IO.File.ReadAllText(fileMailsSend); store = JsonSerializer.Deserialize(json) ?? new ThreadStore(); } return store; } public static List LstRelancesEnAttente() { var store = JsonDb.Load(); var lstReponsesEnAttente = store.Threads .Where(t => !t.SubjectClosed) // Sujet non fermé .Where(t => t.IsOverdue) // En retard .ToList(); return lstReponsesEnAttente; } private class MessageDossier { public UniqueId uid { get; set; } public MimeMessage mimeMessage { get; set; } =new(); public IMailFolder? dossier { get; set; } public string dossierFullname { get; set; } = ""; public string direction { get; set; } = ""; } public static async Task<(bool, string, int)> RunOnce(CompteMailUser parametreUser) { var nbMsgSentSaved = 0; var nbMsgInboxSaved = 0; var nbRelancesCreated = 0; var nbConvReOuvertes = 0; ParametreUser = parametreUser; try { LoggerService.LogInfo("EmailSendService.RunOnce"); var dateFilter = DateTime.UtcNow.AddDays(-1 * ParametreUser.DelaySentRecup); var parametresMail = EmailService.LoadParametres(); if(parametresMail == null) { return (false, "Paramètres email introuvable",0); } var store = JsonDb.Load(); using var client = new ImapClient(); await client.ConnectAsync(parametresMail.ServeurImap, parametresMail.ServeurImapPort, SecureSocketOptions.SslOnConnect); await client.AuthenticateAsync(parametresMail.UserAdresse, parametresMail.UserMotPasse); ListInboxFolders.Clear(); var personal = client.GetFolder(client.PersonalNamespaces[0]); await ProcessFolder(personal); // Récupérer le dossier "Sent" var sentFolder = await personal.GetSubfolderAsync("Sent"); // Si ça ne marche pas (par exemple dossier en français "Envoyés") : if (sentFolder == null || !sentFolder.Exists) { var allFolders = await personal.GetSubfoldersAsync(false); sentFolder = allFolders.FirstOrDefault(f => f.Name.Equals("Sent", StringComparison.OrdinalIgnoreCase) || f.Name.Equals("Envoyés", StringComparison.OrdinalIgnoreCase)); } // Si on a bien trouvé un dossier "Sent" ou équivalent if (sentFolder == null) { return (false, "Dossier mails envoyés introuvables",0); } var sent = sentFolder; await sent.OpenAsync(MailKit.FolderAccess.ReadOnly); //var uidsSent = await sent.SearchAsync(SearchQuery.All); var uidsSent = await sent.SearchAsync(SearchQuery.DeliveredAfter(dateFilter)); List lst = new(); foreach (var uid in uidsSent) { var msg = await sent.GetMessageAsync(uid); MessageDossier nouveau = new(); nouveau.uid = uid; nouveau.mimeMessage = msg; nouveau.dossier = sent; nouveau.dossierFullname = sentFolder.FullName; nouveau.direction = "out"; lst.Add(nouveau); } foreach (var oneFolder in ListInboxFolders) { var inbox = oneFolder!;// client.Inbox; await inbox.OpenAsync(MailKit.FolderAccess.ReadWrite); //var uidsInbox = await inbox.SearchAsync(SearchQuery.All); var uidsInbox = await inbox.SearchAsync(SearchQuery.DeliveredAfter(dateFilter)); foreach (var uid in uidsInbox) { var msg = await inbox.GetMessageAsync(uid); MessageDossier nouveau = new(); nouveau.uid = uid; nouveau.mimeMessage = msg; nouveau.dossier = inbox; nouveau.dossierFullname = inbox.FullName; nouveau.direction = "in"; lst.Add(nouveau); } } lst = lst.OrderBy(e => e.mimeMessage.Date).ToList(); foreach (var message in lst) { var bCreateMsg = await AddMessage(message.uid, store, message.mimeMessage, message.direction,message.dossierFullname, parametreUser.OverdueDaysSent); if (bCreateMsg) nbMsgSentSaved++; JsonDb.Save(store); } /* foreach (var uid in uidsSent) { var msg = await sent.GetMessageAsync(uid); var bCreateMsg = await AddMessage(uid, store, msg, "out", sentFolder.FullName, parametreUser.OverdueDaysSent); if (bCreateMsg) nbMsgSentSaved++; JsonDb.Save(store); } // Tous les dossiers Inbox foreach (var oneFolder in ListInboxFolders) { var inbox = oneFolder!;// client.Inbox; await inbox.OpenAsync(MailKit.FolderAccess.ReadWrite); //var uidsInbox = await inbox.SearchAsync(SearchQuery.All); var uidsInbox = await inbox.SearchAsync(SearchQuery.DeliveredAfter(dateFilter)); foreach (var uid in uidsInbox) { var msg = await inbox.GetMessageAsync(uid); var bCreateMsg = await AddMessage(uid, store, msg, "in", oneFolder.FullName, parametreUser.OverdueDaysSent); if (bCreateMsg) nbMsgInboxSaved++; JsonDb.Save(store); } } */ await client.DisconnectAsync(true); // Analyse IA pour déterminer les relances foreach (var thread in store.Threads) { // On prend le dernier mail sortant var lastOut = thread.Messages .FindLast(m => m.Direction == "out"); var theLast = thread.Messages.FindLast(m => m.Direction == "out" || m.Direction == "in"); thread.DateLastMessage = theLast!.Date; if (lastOut != null && lastOut.RequiresResponse) { // On ne régénère pas une relance si on l'a déjà faite pour ce mail bool alreadyFollowedUp = (thread.LastFollowUpForMessageId == lastOut.Id); if (!alreadyFollowedUp) { // Dernier mail entrant var lastIn = thread.Messages .Where(m => m.Direction == "in") .OrderBy(m => m.Date.ToUniversalTime()) .LastOrDefault(); // relance si bool needsFollowUp = ( lastIn == null // pas de réponse || (lastIn.Date.ToUniversalTime() < lastOut.Date.ToUniversalTime()) // pas de réponse APRES le dernier message || (lastIn.Date.ToUniversalTime() >= lastOut.Date.ToUniversalTime() && !lastIn.CoversAllPoints) // réponse APRES le dernier message MAIS tous les points ne sont pas couverts ); if (needsFollowUp) { //if(thread.FollowUpDraft == null || thread.FollowUpDraft == "") thread.FollowUpDraft = await GenerateFollowUp(thread); // Même si cloturée, on ré-ouvre la conversation if(thread.SubjectClosed == true) { nbConvReOuvertes++; thread.SubjectClosed = false; } thread.LastFollowUpForMessageId = lastOut.Id; nbRelancesCreated++; } } } JsonDb.Save(store); } JsonDb.Save(store); LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre messages envoyés sauvegardés : {nbMsgSentSaved}"); LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre messages reçus sauvegardés : {nbMsgInboxSaved}"); LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre relances générées : {nbRelancesCreated}"); LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre coversation réouvertes : {nbConvReOuvertes}"); LoggerService.LogDebug("EmailSendService.RunOnce : terminé"); return (true, "", nbConvReOuvertes); } catch(Exception ex) { LoggerService.LogError($"EmailSendService.RunOnce : {ex.Message}"); LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre messages envoyés sauvegardés : {nbMsgSentSaved}"); LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre messages reçus sauvegardés : {nbMsgInboxSaved}"); LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre relances générées : {nbRelancesCreated}"); LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre coversation réouvertes : {nbConvReOuvertes}"); return (false, $"EmailSendService.RunOnce : {ex.Message}", nbConvReOuvertes); } } public static bool Save(ThreadItem thread) { LoggerService.LogInfo("EmailSendService.Save"); return JsonDb.Save(thread); } public async static Task ArchiverRestaurerItem(ThreadItemMini thread) { await Task.Delay(1); var store = ChargerDonnees(); var tts = store.Threads; ThreadItem? t = tts!.Where(e => e.Id == thread.Id)!.FirstOrDefault(); if (t == null) { return false; } t.SubjectClosed = !t.SubjectClosed; Save(t); return true; } public async static Task SaveSendRelanceItem(ThreadItemMini thread) { await Task.Delay(1); var store = ChargerDonnees(); var tts = store.Threads; ThreadItem? t = tts!.Where(e=>e.Id == thread.Id)!.FirstOrDefault(); if (t == null) { return false; } t.FollowUpDraft = thread.FollowUpDraft; Save(t); return true; } public async static Task SaveAsync(ThreadItem thread) { await Task.Delay(1); LoggerService.LogInfo("EmailSendService.Save"); return JsonDb.Save(thread); } public static void AllConversationsChangeStatus(bool isClosed) { var store = JsonDb.Load(); foreach(var conversation in store.Threads) { conversation.SubjectClosed = isClosed; } JsonDb.Save(store); } #endregion #region Méthodes privées private static async Task ProcessFolder(IMailFolder folder) { var excludedFolders = new[] { "Junk", "spam", "Sent", "Trash", "Drafts" }; if (Array.Exists(excludedFolders, f => string.Equals(f, folder.Name, StringComparison.OrdinalIgnoreCase))) return; if (folder.Attributes.HasFlag(FolderAttributes.NoSelect)) { LoggerService.LogDebug($"📂 {folder.FullName} (conteneur uniquement, pas de mails)"); } else { ListInboxFolders.Add(folder); LoggerService.LogDebug($"📂 {folder.FullName}"); } // Parcours récursif des sous-dossiers foreach (var sub in folder.GetSubfolders()) { await ProcessFolder(sub); } } private static async Task AddMessage(UniqueId uid, ThreadStore store, MimeMessage msg, string direction, string folder, int overdueDays) { //var folder = (direction == "out" ? "Sent" : "Inbox"); var subject = msg.Subject ?? "(sans objet)"; var body = msg.TextBody ?? msg.HtmlBody ?? ""; var messageId = msg.MessageId ?? Guid.NewGuid().ToString(); var references = msg.References?.ToArray() ?? Array.Empty(); var inReplyTo = msg.InReplyTo; // Trouve un thread existant basé sur Reply-To var thread = (ThreadItem?)null; if (messageId != null) { // On cherche le message par son ID (pour ne pas créer un doublon) thread = store.Threads.Find(t => t.Messages.Exists(m => m.Id == messageId) ); } if (thread == null && inReplyTo != null) { // On cherche si une reponse existe déjà pour mettre ce message dans le même thread thread = store.Threads.Find(t => t.Messages.Exists(m => m.Id == inReplyTo) ); } if (thread == null) { thread = store.Threads.Find(t => t.Messages.Exists(m => m.InReplyTo == messageId) ); } // Vérifie si déjà importé via UID /* if (thread != null && thread.Messages.Exists(m => m.Folder == folder && m.Uid == uid)) return false; */ if (thread != null) { // Si le message existe déjà dans le thread, il a peut être été déplacé de dossier if (thread.Messages.Exists(m => m.Id == msg.MessageId)) { if (thread.Messages.Exists(m => m.Folder == folder && m.Id == msg.MessageId)) { // existe et n'a pas été déplacé : on arrête return false; } else { // a été déplacé --> mettre à jour le folder et uid var msgExist = thread.Messages.Find(m => m.Id == msg.MessageId); if (msgExist != null) { msgExist.Folder = folder; //msgExist.Uid = uid; return true; } else { // cas impossible ou msgExist est null return false; } } } } if (thread == null) { thread = new ThreadItem(overdueDays) { Subject = subject, DateMessage = msg.Date }; store.Threads.Add(thread); } var analysis = await AnalyzeMessage(body, direction); thread.Messages.Add(new MailMessageItem { Id = msg.MessageId!, InReplyTo = msg.InReplyTo, //UidString = uid.ToString(), Folder = folder, Subject = subject, From = msg.From.Mailboxes.FirstOrDefault()?.Address ?? "", To = string.Join("; ", msg.To.Mailboxes.Select(m => m.Address)), Direction = direction, Date = msg.Date, Body = body, RequiresResponse = analysis.requiresResponse, CoversAllPoints = analysis.coversAllPoints }); return true; } private static async Task<(bool requiresResponse, bool coversAllPoints)> AnalyzeMessage(string body, string direction) { try { var prompt = ""; if (direction == "out") { //prompt = $"Réponds uniquement par oui ou non. Une réponse est-elle nécessaire à ce mail\nDébut du mail:\n{body} \n- fin du mail"; prompt = PromptService.GetPrompt(PromptService.ePrompt.EmailSendService_AnalyserMail_BesoinReponse, body); } else { //prompt = $"Réponds uniquement par oui ou non. Tous les points de ce mail ont-ils été traités\nDébut du mail:\n{body} \n- fin du mail"; prompt = PromptService.GetPrompt(PromptService.ePrompt.EmailSendService_AnalyserMail_AllPointsChecked, body); } var (reponseOllama, m) = await _ReActAgent.AppelerLLMAsync(ModelsUseCases.TypeUseCase.AnalyseMails, false, prompt, "Analyse mails sortants"); if(reponseOllama == null || reponseOllama.ToString().Trim().Length ==0) { return (true, true); } if (direction == "out") { var rep = (reponseOllama.ToString().ToLower().Contains("oui")); return (rep, false); } else { var rep = (reponseOllama.ToString().ToLower().Contains("oui")); return (false, rep); } } catch { return (true, true); } } private static async Task GenerateFollowUp(ThreadItem thread) { var conversation = string.Join("\n\n", thread.Messages.ConvertAll(m => $"[{m.Direction}] {m.Body}")); //var prompt = $"Génère un brouillon de mail en langue française de relance poli basé sur cette conversation :\n{conversation}"; var prompt = PromptService.GetPrompt(PromptService.ePrompt.EmailSendService_AnalyserMail_Relance, thread.Subject, conversation, ParametreUser.UserNomPrenom, ParametreUser.UserRole, ParametreUser.UserEntreprise); var (reponseOllama, _) = await _ReActAgent.AppelerLLMAsync(ModelsUseCases.TypeUseCase.AnalyseMails, false, prompt, "Génération mail de relance"); return reponseOllama; } #endregion } #endregion }