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 = $"
";
(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));
}
}