using MimeKit; using Omika.Core.Interfaces; using Omika.Core.Services; namespace Omika.Tests.Services; /// /// Tests E2E de la STRUCTURE MIME des images inline Email : les images doivent /// être inline dans le corps (multipart/related + Content-Disposition:inline + /// cid exact), JAMAIS en pièces jointes classiques (multipart/mixed/attachment). /// public class EmailMimeStructureTests { private static byte[] Jpeg() => [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]; private static byte[] Jpeg2() => [0xFF, 0xD8, 0xFF, 0xE1, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66]; private static byte[] Png() => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00]; private static string DataUri(byte[] bytes, string mime) => $"data:{mime};base64,{Convert.ToBase64String(bytes)}"; private static EmailMessage MessageAvecCorpsHtml(string html, string? corpsTexte = null) => new() { Sujet = "Test", CorpsHtml = html, CorpsTexte = corpsTexte, Destinataires = ["dest@test.fr"] }; private static IEnumerable Parcourir(MimeEntity entity) { if (entity is MimePart part) { yield return part; yield break; } if (entity is Multipart multipart) { foreach (MimeEntity enfant in multipart) { foreach (MimePart descendant in Parcourir(enfant)) { yield return descendant; } } } } private static List Images(MimeMessage mime) => Parcourir(mime.Body).Where(p => p.ContentType.MediaType == "image").ToList(); private static List Attachements(MimeMessage mime) => Parcourir(mime.Body) .Where(p => p.ContentDisposition?.Disposition == ContentDisposition.Attachment) .ToList(); // ------------------------------------------------------------------ // TEST 1 : 2 photos → LinkedResources=2, Attachments=0 // ------------------------------------------------------------------ [Fact] public void DeuxPhotos_DeuxRessourcesInline_ZeroAttachements() { string html = $"" + $"
30/01/2026
31/01/2026
"; MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr"); Assert.Equal(2, Images(mime).Count); Assert.Empty(Attachements(mime)); } // ------------------------------------------------------------------ // TEST 2 : toutes les ressources ont Content-Disposition:inline // ------------------------------------------------------------------ [Fact] public void ToutesRessources_ContentDispositionInline() { string html = $"" + $""; MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr"); List images = Images(mime); Assert.NotEmpty(images); Assert.All(images, img => Assert.Equal(ContentDisposition.Inline, img.ContentDisposition?.Disposition)); } // ------------------------------------------------------------------ // TEST 3 : HTML cid ↔ ContentId exact // ------------------------------------------------------------------ [Fact] public void HtmlCid_CorrespondExactementContentId() { string html = $""; MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr"); string htmlFinal = mime.HtmlBody; Assert.NotNull(htmlFinal); List images = Images(mime); Assert.Single(images); string cid = images[0].ContentId; Assert.False(string.IsNullOrEmpty(cid)); Assert.Contains($"src=\"cid:{cid}\"", htmlFinal); // Le HTML ne contient pas les chevrons <…> du header MIME. Assert.DoesNotContain($"cid:<{cid}>", htmlFinal); } // ------------------------------------------------------------------ // TEST 4 : multipart/related contient les images // ------------------------------------------------------------------ [Fact] public void Images_DansMultipartRelated() { string html = $""; MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr"); // Sans texte alternatif : corps racine = multipart/related (MultipartRelated). Assert.IsAssignableFrom(mime.Body); Assert.Equal("related", mime.Body.ContentType.MediaSubtype); Assert.Single(Images(mime)); } // ------------------------------------------------------------------ // TEST 5 : aucune image sous multipart/mixed (pièce jointe) // ------------------------------------------------------------------ [Fact] public void AucuneImage_SousMultipartMixed() { string html = $"" + $""; MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr"); Assert.Empty(Attachements(mime)); // Aucune image ne doit se trouver sous un conteneur multipart/mixed. foreach (MimePart image in Images(mime)) { Assert.False(ParentMixed(mime.Body, image), "Une image inline est tombée sous multipart/mixed."); } } // ------------------------------------------------------------------ // TEST 6 : 2 photos + signature → 3 ressources distinctes // ------------------------------------------------------------------ [Fact] public void DeuxPhotos_UneSignature_TroisRessourcesDistinctes() { string html = $"" + $"" + $""; MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr"); List images = Images(mime); Assert.Equal(3, images.Count); Assert.Equal(3, images.Select(i => i.ContentId).Distinct().Count()); } // ------------------------------------------------------------------ // TEST 7 : plusieurs data URI → toutes converties (aucune ignorée) // ------------------------------------------------------------------ [Fact] public void PlusieursDataUri_ToutesConverties() { string html = $"" + $"" + $""; (string htmlSmtp, IReadOnlyList ressources) = EmailSmtpInline.Convertir(html); Assert.Equal(3, ressources.Count); Assert.DoesNotContain("data:", htmlSmtp); Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(htmlSmtp, "cid:").Count); } // ------------------------------------------------------------------ // TEST 8 : attributs img différents (guillemets simples / ordre) → robuste // ------------------------------------------------------------------ [Fact] public void AttributsImgDifferents_ConversionRobuste() { // Guillemets simples + attribut style AVANT src. string a = $""; // Guillemets doubles + src en premier. string b = $"\"sig\""; (string htmlSmtp, IReadOnlyList ressources) = EmailSmtpInline.Convertir(a + b); Assert.Equal(2, ressources.Count); Assert.DoesNotContain("data:", htmlSmtp); Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(htmlSmtp, "cid:").Count); } // ------------------------------------------------------------------ // TEST 9 : .eml sauvegardé → relecture MimeKit → images inline retrouvées // ------------------------------------------------------------------ [Fact] public void Eml_SauvegardePuisRelu_ImagesInlineRetrouvees() { string html = $"" + $""; MimeMessage mime = MailKitEmailSender.ConstruireMime(MessageAvecCorpsHtml(html), "exp@test.fr"); string repertoire = Path.Combine(Path.GetTempPath(), "omika-eml-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(repertoire); string eml = Path.Combine(repertoire, "email-inline-diagnostic.eml"); mime.WriteTo(eml); MimeMessage relu = MimeMessage.Load(eml); Assert.Equal(2, Images(relu).Count); Assert.All(Images(relu), img => Assert.Equal(ContentDisposition.Inline, img.ContentDisposition?.Disposition)); Assert.Empty(Attachements(relu)); } // ------------------------------------------------------------------ // TEST 10 : Batik non-régression (pas d'image → aucun changement) // ------------------------------------------------------------------ [Fact] public void Batik_SansImage_AucuneRessourceInline() { // Batik : corps HTML simple (aucune image) → aucune ressource inline. MimeMessage mime = MailKitEmailSender.ConstruireMime( MessageAvecCorpsHtml("

Bonjour Batik

", corpsTexte: "Bonjour Batik"), "exp@test.fr"); Assert.Empty(Images(mime)); Assert.Empty(Attachements(mime)); Assert.Contains("Bonjour Batik", mime.HtmlBody); } private static bool ParentMixed(MimeEntity parent, MimePart cible) { if (parent is not Multipart multipart) { return false; } if (multipart.ContentType.MediaSubtype == "mixed" && multipart.Contains(cible)) { return true; } return multipart.OfType().Any(enfant => ParentMixed(enfant, cible)); } }