HTML mail no longer crashes WebView2 on Windows. Large messages with inline images load from a local file, and WebView failures fall back to text. The message list shows each item's size so heavy mail is easy to sort and delete, and the paperclip stays on mail whose files are not marked as attachments. Search results keep the message context menu after you select mail.
+Selected mail keeps its preview while the folder list refreshes. Delete and move use the row's folder, and opening a message is not stuck behind a catalog scan.
diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 11e0238..edfa7f3 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@A\0B
"); + Assert.DoesNotContain('\0', html); + Assert.Contains("AB
", html, StringComparison.Ordinal); + } } @@ -281,6 +338,17 @@ public class MailWhenTests { } +public class MailSizeTests { + [Fact] + public void Line_FormatsBytesAndHidesUnknown() { + Assert.Equal("", MailSize.Line(0)); + Assert.Equal("512 B", MailSize.Line(512)); + Assert.Equal("1.5 KB", MailSize.Line(1536)); + Assert.Equal("2 MB", MailSize.Line(2 * 1024 * 1024)); + } +} + + public class MailIdTests { [Fact] public void Parent_PrefersInReplyToThenLastReferences() { @@ -345,6 +413,27 @@ public class MailMessageListTests { Assert.Same(live, Assert.Single(resolved)); } + [Fact] + public void Live_RemapsCurrentOntoSelectedRows() { + var stale = new Row { Id = 2 }; + Row[] selected = [new() { Id = 1 }, new() { Id = 2 }, new() { Id = 3 }]; + var live = MailMessageList.Live(selected, stale, row => row.Key); + Assert.Same(selected[1], live); + } + + [Fact] + public void Live_FallsBackToFirstWhenCurrentMissing() { + var gone = new Row { Id = 9 }; + Row[] selected = [new() { Id = 1 }, new() { Id = 2 }]; + Assert.Same(selected[0], MailMessageList.Live(selected, gone, row => row.Key)); + } + + [Fact] + public void Live_ClearsCurrentWhenSelectionEmpty() { + var current = new Row { Id = 4 }; + Assert.Null(MailMessageList.Live([], current, row => row.Key)); + } + [Fact] public void CoversAll_RequiresMatchingKeys() { Row[] visible = [new() { Id = 1 }, new() { Id = 2 }]; @@ -436,7 +525,8 @@ public class MailIndexProgressTests { [Fact] public void Line_ShowsDoneOfTotal() { Assert.Equal("Indexing 12 / 400", MailIndexProgress.Line(12, 400)); - Assert.Equal("Indexing 400 / 400", MailIndexProgress.Line(500, 400)); + Assert.Equal("", MailIndexProgress.Line(400, 400)); + Assert.Equal("", MailIndexProgress.Line(500, 400)); } [Fact] diff --git a/src/MaksIT.PostClient.Tests/ArchiveTests.cs b/src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs similarity index 87% rename from src/MaksIT.PostClient.Tests/ArchiveTests.cs rename to src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs index ee5e88a..8b2cf1b 100644 --- a/src/MaksIT.PostClient.Tests/ArchiveTests.cs +++ b/src/MaksIT.PostClient.Tests/Archive/ArchiveTests.cs @@ -1,8 +1,6 @@ -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Archive; public class FatturaPaTests { @@ -193,6 +191,102 @@ public class MailArchiveStoreTests { } } + [Fact] + public void UpsertHeaders_KeepsPreviousSizeWhenIncomingIsZero() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 5, + Folder = "INBOX", + Subject = "Heavy", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + Size = 2_048_000 + } + ]); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 5, + Folder = "INBOX", + Subject = "Heavy", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + Size = 0 + } + ]); + var row = Assert.Single(store.ListFolder("box", "INBOX")); + Assert.Equal(2_048_000, row.Size); + } + finally { + DeleteArchive(path); + } + } + + [Fact] + public void UpsertHeaders_KeepsAttachmentsWhenIncomingIsFalse() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 6, + Folder = "INBOX", + Subject = "With file", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + HasAttachments = true + } + ]); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 6, + Folder = "INBOX", + Subject = "With file", + From = "a@b.c", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + HasAttachments = false + } + ]); + var row = Assert.Single(store.ListFolder("box", "INBOX")); + Assert.True(row.HasAttachments); + } + finally { + DeleteArchive(path); + } + } + + [Fact] + public void MissingBodies_PrefersKnownSmallerMessages() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.UpsertHeaders("box", [ + new MailArchiveHeader { + Uid = 1, + Folder = "INBOX", + Subject = "Huge", + Date = DateTimeOffset.Parse("2026-01-02T10:00:00Z"), + Size = 9_000_000 + }, + new MailArchiveHeader { + Uid = 2, + Folder = "INBOX", + Subject = "Small", + Date = DateTimeOffset.Parse("2026-01-01T10:00:00Z"), + Size = 1200 + } + ]); + var first = Assert.Single(store.MissingBodies(["box"], 1)); + Assert.Equal(2u, first.Uid); + Assert.Equal(1200, first.Size); + } + finally { + DeleteArchive(path); + } + } + [Fact] public void RemoveUids_LeavesOtherMessages() { var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); @@ -640,6 +734,18 @@ public class MailArchiveMapTests { EnvelopeTipo = "avvenuta-consegna" }); Assert.Equal(ReceiptStatus.Delivered, header.DeliveryStatus); + Assert.Equal(0, header.Size); + } + + [Fact] + public void FromHeader_CopiesSize() { + var mapped = MailArchiveMap.FromHeader(new MailMessageHeader { + Id = 2, + Folder = "INBOX", + Size = 4096 + }); + Assert.Equal(4096, mapped.Size); + Assert.Equal(4096, MailArchiveMap.ToHeader(mapped).Size); } } diff --git a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs b/src/MaksIT.PostClient.Tests/Archive/StoreAndHubTests.cs similarity index 99% rename from src/MaksIT.PostClient.Tests/StoreAndHubTests.cs rename to src/MaksIT.PostClient.Tests/Archive/StoreAndHubTests.cs index 6f2a2d7..cd2eb83 100644 --- a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs +++ b/src/MaksIT.PostClient.Tests/Archive/StoreAndHubTests.cs @@ -4,11 +4,9 @@ using MaksIT.IdentityHub.Contracts.Health; using MaksIT.IdentityHub.Contracts.Identity; using MaksIT.IdentityHub.Contracts.Identity.Login; using MaksIT.IdentityHub.Contracts.Identity.Mailbox; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Archive; public class IdentityHubAddressTests { diff --git a/src/MaksIT.PostClient.Tests/OAuthTests.cs b/src/MaksIT.PostClient.Tests/Auth/OAuthTests.cs similarity index 98% rename from src/MaksIT.PostClient.Tests/OAuthTests.cs rename to src/MaksIT.PostClient.Tests/Auth/OAuthTests.cs index a41d962..fc3ebdc 100644 --- a/src/MaksIT.PostClient.Tests/OAuthTests.cs +++ b/src/MaksIT.PostClient.Tests/Auth/OAuthTests.cs @@ -1,9 +1,7 @@ using System.Text; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Auth; public class MailAuthKindTests { diff --git a/src/MaksIT.PostClient.Tests/EnvelopeTests.cs b/src/MaksIT.PostClient.Tests/Envelope/EnvelopeTests.cs similarity index 88% rename from src/MaksIT.PostClient.Tests/EnvelopeTests.cs rename to src/MaksIT.PostClient.Tests/Envelope/EnvelopeTests.cs index 1ebb717..e79dbee 100644 --- a/src/MaksIT.PostClient.Tests/EnvelopeTests.cs +++ b/src/MaksIT.PostClient.Tests/Envelope/EnvelopeTests.cs @@ -1,9 +1,7 @@ using MimeKit; -using MaksIT.PostClient.Client; -using MaksIT.PostClient.Shared; -namespace MaksIT.PostClient.Tests; +namespace MaksIT.PostClient.Tests.Envelope; public class EnvelopeClassifierTests { @@ -321,4 +319,43 @@ public class MimeBodyTests { Assert.False(body.Header.IsSeen); Assert.False(body.Header.IsFlagged); } + + [Fact] + public async Task FromMimeAsync_NamedInlinePartCountsAsAttachment() { + var mime = new MimeMessage(); + mime.From.Add(new MailboxAddress("A", "a@b.c")); + mime.To.Add(new MailboxAddress("B", "b@c.d")); + mime.Subject = "Invoice"; + var pdf = new MimePart("application", "pdf") { + Content = new MimeContent(new MemoryStream("pdf"u8.ToArray())), + ContentDisposition = new ContentDisposition(ContentDisposition.Inline), + FileName = "invoice.pdf" + }; + mime.Body = new Multipart("mixed") { + new TextPart("plain") { Text = "See attached." }, + pdf + }; + var body = await MimeBody.FromMimeAsync("INBOX", 9, mime, CancellationToken.None); + Assert.True(body.Header.HasAttachments); + Assert.Contains(body.AttachmentFiles, f => f.Name == "invoice.pdf"); + } + + [Fact] + public async Task FromMimeAsync_CidImageInHtmlIsNotAttachment() { + var mime = new MimeMessage(); + mime.From.Add(new MailboxAddress("A", "a@b.c")); + mime.To.Add(new MailboxAddress("B", "b@c.d")); + mime.Subject = "Logo"; + var image = new MimePart("image", "png") { + Content = new MimeContent(new MemoryStream([0x89, 0x50])), + ContentId = "logo@mail", + ContentDisposition = new ContentDisposition(ContentDisposition.Inline) + }; + mime.Body = new Multipart("related") { + new TextPart("html") { Text = "