Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

545 lines
22KB

  1. using ClosedXML;
  2. using Google.Protobuf.WellKnownTypes;
  3. using MailKit;
  4. using MailKit.Net.Imap;
  5. using MailKit.Search;
  6. using MailKit.Security;
  7. using MimeKit;
  8. using Org.BouncyCastle.Security;
  9. using Services.ReActAgent;
  10. using System.Text.Json;
  11. using ToolsServices;
  12. namespace Services
  13. {
  14. #region Classe static annexe JsonDb
  15. public static class JsonDb
  16. {
  17. static string FullFileName = FichiersInternesService.ListeMailsSend;
  18. public static ThreadStore Load()
  19. {
  20. if (!File.Exists(FullFileName)) return new ThreadStore();
  21. var json = File.ReadAllText(FullFileName);
  22. return JsonSerializer.Deserialize<ThreadStore>(json) ?? new ThreadStore();
  23. }
  24. public static bool Save(ThreadItem thread)
  25. {
  26. ThreadStore data;
  27. // Charger fichier existant
  28. if (File.Exists(FullFileName))
  29. {
  30. string json = File.ReadAllText(FullFileName);
  31. data = JsonSerializer.Deserialize<ThreadStore>(json, new JsonSerializerOptions { WriteIndented = true }) ?? new ThreadStore();
  32. }
  33. else
  34. {
  35. data = new ThreadStore();
  36. }
  37. // Chercher si le thread existe déjà
  38. var existing = data.Threads.FirstOrDefault(t => t.Id == thread.Id);
  39. if (existing != null)
  40. {
  41. int index = data.Threads.IndexOf(existing);
  42. data.Threads[index] = thread; // remplacement
  43. }
  44. else
  45. {
  46. data.Threads.Add(thread); // ajout
  47. }
  48. // Sauvegarder
  49. string updatedJson = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
  50. File.WriteAllText(FullFileName, updatedJson);
  51. return true;
  52. }
  53. public static void Save(ThreadStore store)
  54. {
  55. Directory.CreateDirectory(Path.GetDirectoryName(FullFileName)!);
  56. var json = JsonSerializer.Serialize(store, new JsonSerializerOptions { WriteIndented = true });
  57. var tmp = FullFileName + ".tmp";
  58. File.WriteAllText(tmp, json);
  59. File.Move(tmp, FullFileName, true);
  60. }
  61. }
  62. #endregion
  63. #region Classe static EmailSendService
  64. public static class EmailSendService
  65. {
  66. #region Variables
  67. private static ReActAgent.ReActAgent _ReActAgent = new();
  68. private static List<IMailFolder> ListInboxFolders = new List<IMailFolder>();
  69. private static CompteMailUser ParametreUser = new();
  70. #endregion
  71. #region Méthodes publiques
  72. public static int NbRelancesEnAttente()
  73. {
  74. var lstReponsesEnAttente = LstRelancesEnAttente();
  75. return lstReponsesEnAttente.Count;
  76. }
  77. public static ThreadStore ChargerDonnees()
  78. {
  79. var fileMailsSend = FichiersInternesService.ListeMailsSend;
  80. ThreadStore store = new ThreadStore();
  81. if (File.Exists(fileMailsSend))
  82. {
  83. var json = System.IO.File.ReadAllText(fileMailsSend);
  84. store = JsonSerializer.Deserialize<ThreadStore>(json) ?? new ThreadStore();
  85. }
  86. return store;
  87. }
  88. public static List<ThreadItem> LstRelancesEnAttente()
  89. {
  90. var store = JsonDb.Load();
  91. var lstReponsesEnAttente = store.Threads
  92. .Where(t => !t.SubjectClosed) // Sujet non fermé
  93. .Where(t => t.IsOverdue) // En retard
  94. .ToList();
  95. return lstReponsesEnAttente;
  96. }
  97. private class MessageDossier
  98. {
  99. public UniqueId uid { get; set; }
  100. public MimeMessage mimeMessage { get; set; } =new();
  101. public IMailFolder? dossier { get; set; }
  102. public string dossierFullname { get; set; } = "";
  103. public string direction { get; set; } = "";
  104. }
  105. public static async Task<(bool, string, int)> RunOnce(CompteMailUser parametreUser)
  106. {
  107. var nbMsgSentSaved = 0;
  108. var nbMsgInboxSaved = 0;
  109. var nbRelancesCreated = 0;
  110. var nbConvReOuvertes = 0;
  111. ParametreUser = parametreUser;
  112. try
  113. {
  114. LoggerService.LogInfo("EmailSendService.RunOnce");
  115. var dateFilter = DateTime.UtcNow.AddDays(-1 * ParametreUser.DelaySentRecup);
  116. var parametresMail = EmailService.LoadParametres();
  117. if(parametresMail == null)
  118. {
  119. return (false, "Paramètres email introuvable",0);
  120. }
  121. var store = JsonDb.Load();
  122. using var client = new ImapClient();
  123. await client.ConnectAsync(parametresMail.ServeurImap, parametresMail.ServeurImapPort, SecureSocketOptions.SslOnConnect);
  124. await client.AuthenticateAsync(parametresMail.UserAdresse, parametresMail.UserMotPasse);
  125. ListInboxFolders.Clear();
  126. var personal = client.GetFolder(client.PersonalNamespaces[0]);
  127. await ProcessFolder(personal);
  128. // Récupérer le dossier "Sent"
  129. var sentFolder = await personal.GetSubfolderAsync("Sent");
  130. // Si ça ne marche pas (par exemple dossier en français "Envoyés") :
  131. if (sentFolder == null || !sentFolder.Exists)
  132. {
  133. var allFolders = await personal.GetSubfoldersAsync(false);
  134. sentFolder = allFolders.FirstOrDefault(f => f.Name.Equals("Sent", StringComparison.OrdinalIgnoreCase)
  135. || f.Name.Equals("Envoyés", StringComparison.OrdinalIgnoreCase));
  136. }
  137. // Si on a bien trouvé un dossier "Sent" ou équivalent
  138. if (sentFolder == null)
  139. {
  140. return (false, "Dossier mails envoyés introuvables",0);
  141. }
  142. var sent = sentFolder;
  143. await sent.OpenAsync(MailKit.FolderAccess.ReadOnly);
  144. //var uidsSent = await sent.SearchAsync(SearchQuery.All);
  145. var uidsSent = await sent.SearchAsync(SearchQuery.DeliveredAfter(dateFilter));
  146. List<MessageDossier> lst = new();
  147. foreach (var uid in uidsSent)
  148. {
  149. var msg = await sent.GetMessageAsync(uid);
  150. MessageDossier nouveau = new();
  151. nouveau.uid = uid;
  152. nouveau.mimeMessage = msg;
  153. nouveau.dossier = sent;
  154. nouveau.dossierFullname = sentFolder.FullName;
  155. nouveau.direction = "out";
  156. lst.Add(nouveau);
  157. }
  158. foreach (var oneFolder in ListInboxFolders)
  159. {
  160. var inbox = oneFolder!;// client.Inbox;
  161. await inbox.OpenAsync(MailKit.FolderAccess.ReadWrite);
  162. //var uidsInbox = await inbox.SearchAsync(SearchQuery.All);
  163. var uidsInbox = await inbox.SearchAsync(SearchQuery.DeliveredAfter(dateFilter));
  164. foreach (var uid in uidsInbox)
  165. {
  166. var msg = await inbox.GetMessageAsync(uid);
  167. MessageDossier nouveau = new();
  168. nouveau.uid = uid;
  169. nouveau.mimeMessage = msg;
  170. nouveau.dossier = inbox;
  171. nouveau.dossierFullname = inbox.FullName;
  172. nouveau.direction = "in";
  173. lst.Add(nouveau);
  174. }
  175. }
  176. lst = lst.OrderBy(e => e.mimeMessage.Date).ToList();
  177. foreach (var message in lst)
  178. {
  179. var bCreateMsg = await AddMessage(message.uid, store, message.mimeMessage, message.direction,message.dossierFullname, parametreUser.OverdueDaysSent);
  180. if (bCreateMsg)
  181. nbMsgSentSaved++;
  182. JsonDb.Save(store);
  183. }
  184. /*
  185. foreach (var uid in uidsSent)
  186. {
  187. var msg = await sent.GetMessageAsync(uid);
  188. var bCreateMsg = await AddMessage(uid, store, msg, "out", sentFolder.FullName, parametreUser.OverdueDaysSent);
  189. if (bCreateMsg)
  190. nbMsgSentSaved++;
  191. JsonDb.Save(store);
  192. }
  193. // Tous les dossiers Inbox
  194. foreach (var oneFolder in ListInboxFolders)
  195. {
  196. var inbox = oneFolder!;// client.Inbox;
  197. await inbox.OpenAsync(MailKit.FolderAccess.ReadWrite);
  198. //var uidsInbox = await inbox.SearchAsync(SearchQuery.All);
  199. var uidsInbox = await inbox.SearchAsync(SearchQuery.DeliveredAfter(dateFilter));
  200. foreach (var uid in uidsInbox)
  201. {
  202. var msg = await inbox.GetMessageAsync(uid);
  203. var bCreateMsg = await AddMessage(uid, store, msg, "in", oneFolder.FullName, parametreUser.OverdueDaysSent);
  204. if (bCreateMsg)
  205. nbMsgInboxSaved++;
  206. JsonDb.Save(store);
  207. }
  208. }
  209. */
  210. await client.DisconnectAsync(true);
  211. // Analyse IA pour déterminer les relances
  212. foreach (var thread in store.Threads)
  213. {
  214. // On prend le dernier mail sortant
  215. var lastOut = thread.Messages
  216. .FindLast(m => m.Direction == "out");
  217. var theLast = thread.Messages.FindLast(m => m.Direction == "out" || m.Direction == "in");
  218. thread.DateLastMessage = theLast!.Date;
  219. if (lastOut != null && lastOut.RequiresResponse)
  220. {
  221. // On ne régénère pas une relance si on l'a déjà faite pour ce mail
  222. bool alreadyFollowedUp = (thread.LastFollowUpForMessageId == lastOut.Id);
  223. if (!alreadyFollowedUp)
  224. {
  225. // Dernier mail entrant
  226. var lastIn = thread.Messages
  227. .Where(m => m.Direction == "in")
  228. .OrderBy(m => m.Date.ToUniversalTime())
  229. .LastOrDefault();
  230. // relance si
  231. bool needsFollowUp = (
  232. lastIn == null // pas de réponse
  233. || (lastIn.Date.ToUniversalTime() < lastOut.Date.ToUniversalTime()) // pas de réponse APRES le dernier message
  234. || (lastIn.Date.ToUniversalTime() >= lastOut.Date.ToUniversalTime() && !lastIn.CoversAllPoints) // réponse APRES le dernier message MAIS tous les points ne sont pas couverts
  235. );
  236. if (needsFollowUp)
  237. {
  238. //if(thread.FollowUpDraft == null || thread.FollowUpDraft == "")
  239. thread.FollowUpDraft = await GenerateFollowUp(thread);
  240. // Même si cloturée, on ré-ouvre la conversation
  241. if(thread.SubjectClosed == true)
  242. {
  243. nbConvReOuvertes++;
  244. thread.SubjectClosed = false;
  245. }
  246. thread.LastFollowUpForMessageId = lastOut.Id;
  247. nbRelancesCreated++;
  248. }
  249. }
  250. }
  251. JsonDb.Save(store);
  252. }
  253. JsonDb.Save(store);
  254. LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre messages envoyés sauvegardés : {nbMsgSentSaved}");
  255. LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre messages reçus sauvegardés : {nbMsgInboxSaved}");
  256. LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre relances générées : {nbRelancesCreated}");
  257. LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre coversation réouvertes : {nbConvReOuvertes}");
  258. LoggerService.LogDebug("EmailSendService.RunOnce : terminé");
  259. return (true, "", nbConvReOuvertes);
  260. }
  261. catch(Exception ex)
  262. {
  263. LoggerService.LogError($"EmailSendService.RunOnce : {ex.Message}");
  264. LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre messages envoyés sauvegardés : {nbMsgSentSaved}");
  265. LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre messages reçus sauvegardés : {nbMsgInboxSaved}");
  266. LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre relances générées : {nbRelancesCreated}");
  267. LoggerService.LogDebug($"EmailSendService.RunOnce : Nbre coversation réouvertes : {nbConvReOuvertes}");
  268. return (false, $"EmailSendService.RunOnce : {ex.Message}", nbConvReOuvertes);
  269. }
  270. }
  271. public static bool Save(ThreadItem thread)
  272. {
  273. LoggerService.LogInfo("EmailSendService.Save");
  274. return JsonDb.Save(thread);
  275. }
  276. public async static Task<bool> ArchiverRestaurerItem(ThreadItemMini thread)
  277. {
  278. await Task.Delay(1);
  279. var store = ChargerDonnees();
  280. var tts = store.Threads;
  281. ThreadItem? t = tts!.Where(e => e.Id == thread.Id)!.FirstOrDefault();
  282. if (t == null)
  283. {
  284. return false;
  285. }
  286. t.SubjectClosed = !t.SubjectClosed;
  287. Save(t);
  288. return true;
  289. }
  290. public async static Task<bool> SaveSendRelanceItem(ThreadItemMini thread)
  291. {
  292. await Task.Delay(1);
  293. var store = ChargerDonnees();
  294. var tts = store.Threads;
  295. ThreadItem? t = tts!.Where(e=>e.Id == thread.Id)!.FirstOrDefault();
  296. if (t == null)
  297. {
  298. return false;
  299. }
  300. t.FollowUpDraft = thread.FollowUpDraft;
  301. Save(t);
  302. return true;
  303. }
  304. public async static Task<bool> SaveAsync(ThreadItem thread)
  305. {
  306. await Task.Delay(1);
  307. LoggerService.LogInfo("EmailSendService.Save");
  308. return JsonDb.Save(thread);
  309. }
  310. public static void AllConversationsChangeStatus(bool isClosed)
  311. {
  312. var store = JsonDb.Load();
  313. foreach(var conversation in store.Threads)
  314. {
  315. conversation.SubjectClosed = isClosed;
  316. }
  317. JsonDb.Save(store);
  318. }
  319. #endregion
  320. #region Méthodes privées
  321. private static async Task ProcessFolder(IMailFolder folder)
  322. {
  323. var excludedFolders = new[] { "Junk", "spam", "Sent", "Trash", "Drafts" };
  324. if (Array.Exists(excludedFolders, f => string.Equals(f, folder.Name, StringComparison.OrdinalIgnoreCase)))
  325. return;
  326. if (folder.Attributes.HasFlag(FolderAttributes.NoSelect))
  327. {
  328. LoggerService.LogDebug($"📂 {folder.FullName} (conteneur uniquement, pas de mails)");
  329. }
  330. else
  331. {
  332. ListInboxFolders.Add(folder);
  333. LoggerService.LogDebug($"📂 {folder.FullName}");
  334. }
  335. // Parcours récursif des sous-dossiers
  336. foreach (var sub in folder.GetSubfolders())
  337. {
  338. await ProcessFolder(sub);
  339. }
  340. }
  341. private static async Task<bool> AddMessage(UniqueId uid, ThreadStore store, MimeMessage msg, string direction, string folder, int overdueDays)
  342. {
  343. //var folder = (direction == "out" ? "Sent" : "Inbox");
  344. var subject = msg.Subject ?? "(sans objet)";
  345. var body = msg.TextBody ?? msg.HtmlBody ?? "";
  346. var messageId = msg.MessageId ?? Guid.NewGuid().ToString();
  347. var references = msg.References?.ToArray() ?? Array.Empty<string>();
  348. var inReplyTo = msg.InReplyTo;
  349. // Trouve un thread existant basé sur Reply-To
  350. var thread = (ThreadItem?)null;
  351. if (messageId != null)
  352. {
  353. // On cherche le message par son ID (pour ne pas créer un doublon)
  354. thread = store.Threads.Find(t =>
  355. t.Messages.Exists(m => m.Id == messageId)
  356. );
  357. }
  358. if (thread == null && inReplyTo != null)
  359. {
  360. // On cherche si une reponse existe déjà pour mettre ce message dans le même thread
  361. thread = store.Threads.Find(t =>
  362. t.Messages.Exists(m => m.Id == inReplyTo)
  363. );
  364. }
  365. if (thread == null)
  366. {
  367. thread = store.Threads.Find(t =>
  368. t.Messages.Exists(m => m.InReplyTo == messageId)
  369. );
  370. }
  371. // Vérifie si déjà importé via UID
  372. /*
  373. if (thread != null && thread.Messages.Exists(m => m.Folder == folder && m.Uid == uid))
  374. return false;
  375. */
  376. if (thread != null)
  377. {
  378. // Si le message existe déjà dans le thread, il a peut être été déplacé de dossier
  379. if (thread.Messages.Exists(m => m.Id == msg.MessageId))
  380. {
  381. if (thread.Messages.Exists(m => m.Folder == folder && m.Id == msg.MessageId))
  382. {
  383. // existe et n'a pas été déplacé : on arrête
  384. return false;
  385. }
  386. else
  387. {
  388. // a été déplacé --> mettre à jour le folder et uid
  389. var msgExist = thread.Messages.Find(m => m.Id == msg.MessageId);
  390. if (msgExist != null)
  391. {
  392. msgExist.Folder = folder;
  393. //msgExist.Uid = uid;
  394. return true;
  395. }
  396. else
  397. {
  398. // cas impossible ou msgExist est null
  399. return false;
  400. }
  401. }
  402. }
  403. }
  404. if (thread == null)
  405. {
  406. thread = new ThreadItem(overdueDays) { Subject = subject, DateMessage = msg.Date };
  407. store.Threads.Add(thread);
  408. }
  409. var analysis = await AnalyzeMessage(body, direction);
  410. thread.Messages.Add(new MailMessageItem
  411. {
  412. Id = msg.MessageId!,
  413. InReplyTo = msg.InReplyTo,
  414. //UidString = uid.ToString(),
  415. Folder = folder,
  416. Subject = subject,
  417. From = msg.From.Mailboxes.FirstOrDefault()?.Address ?? "",
  418. To = string.Join("; ", msg.To.Mailboxes.Select(m => m.Address)),
  419. Direction = direction,
  420. Date = msg.Date,
  421. Body = body,
  422. RequiresResponse = analysis.requiresResponse,
  423. CoversAllPoints = analysis.coversAllPoints
  424. });
  425. return true;
  426. }
  427. private static async Task<(bool requiresResponse, bool coversAllPoints)> AnalyzeMessage(string body, string direction)
  428. {
  429. try
  430. {
  431. var prompt = "";
  432. if (direction == "out")
  433. {
  434. //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";
  435. prompt = PromptService.GetPrompt(PromptService.ePrompt.EmailSendService_AnalyserMail_BesoinReponse, body);
  436. }
  437. else
  438. {
  439. //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";
  440. prompt = PromptService.GetPrompt(PromptService.ePrompt.EmailSendService_AnalyserMail_AllPointsChecked, body);
  441. }
  442. var (reponseOllama, m) = await _ReActAgent.AppelerLLMAsync(ModelsUseCases.TypeUseCase.AnalyseMails, false, prompt, "Analyse mails sortants");
  443. if(reponseOllama == null || reponseOllama.ToString().Trim().Length ==0)
  444. {
  445. return (true, true);
  446. }
  447. if (direction == "out")
  448. {
  449. var rep = (reponseOllama.ToString().ToLower().Contains("oui"));
  450. return (rep, false);
  451. }
  452. else
  453. {
  454. var rep = (reponseOllama.ToString().ToLower().Contains("oui"));
  455. return (false, rep);
  456. }
  457. }
  458. catch
  459. {
  460. return (true, true);
  461. }
  462. }
  463. private static async Task<string> GenerateFollowUp(ThreadItem thread)
  464. {
  465. var conversation = string.Join("\n\n", thread.Messages.ConvertAll(m => $"[{m.Direction}] {m.Body}"));
  466. //var prompt = $"Génère un brouillon de mail en langue française de relance poli basé sur cette conversation :\n{conversation}";
  467. var prompt = PromptService.GetPrompt(PromptService.ePrompt.EmailSendService_AnalyserMail_Relance, thread.Subject, conversation, ParametreUser.UserNomPrenom, ParametreUser.UserRole, ParametreUser.UserEntreprise);
  468. var (reponseOllama, _) = await _ReActAgent.AppelerLLMAsync(ModelsUseCases.TypeUseCase.AnalyseMails, false, prompt, "Génération mail de relance");
  469. return reponseOllama;
  470. }
  471. #endregion
  472. }
  473. #endregion
  474. }