From c06a387b3c45db78190595f28eacac8ba2e14716 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 22:03:16 -0500 Subject: [PATCH] fix(mailbox): derive plain-text preview snippets from HTML bodies snippetOf() stripped only tag delimiters with a single regex on every body, HTML or not:

Visible

", + isHtml = true, + ) + assertEquals("Visible", snippet) + } + + @Test + fun `html entities are decoded including numeric references`() { + assertEquals( + "Tom & Jerry — \"friends\" don’t fight", + Snippet.of("Tom & Jerry — "friends" don’t fight", isHtml = true), + ) + } + + @Test + fun `html nbsp becomes a plain space and runs of whitespace collapse`() { + assertEquals("a b c", Snippet.of("a b\n\t c", isHtml = true)) + } + + @Test + fun `html snippet is capped after markup is removed`() { + val body = "

" + "x".repeat(500) + "

" + val snippet = Snippet.of(body, isHtml = true) + assertEquals("x".repeat(Snippet.MAX_LENGTH), snippet) + } + + @Test + fun `plain text keeps literal angle brackets`() { + assertEquals( + "From : 3 < 5 and x > y", + Snippet.of("From : 3 < 5 and x > y", isHtml = false), + ) + } + + @Test + fun `plain text is not entity-decoded`() { + assertEquals("Fish & chips", Snippet.of("Fish & chips", isHtml = false)) + } + + @Test + fun `plain text collapses whitespace and trims`() { + assertEquals("one two three", Snippet.of(" one\r\n two\t\tthree \n", isHtml = false)) + } + + @Test + fun `plain text is capped at the max length`() { + assertEquals("y".repeat(Snippet.MAX_LENGTH), Snippet.of("y".repeat(1_000), isHtml = false)) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index 5773d14..712d003 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -125,6 +125,46 @@ class MailRepositoryImplTest { coVerify { imapClient.fetchBodyMarkingSeen(any(), "Archive", "5") } } + @Test + fun `openMessage derives a readable plain-text snippet from an HTML body`() = runTest { + val id = "acct:INBOX:20" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { imapClient.fetchBodyMarkingSeen(any(), "INBOX", "20") } returns MessageContent( + "

Tom & Jerry say "hi"

", + isHtml = true, + ) + val snippet = slot() + coEvery { messageDao.updateBody(id, any(), any(), capture(snippet)) } just Runs + coEvery { messageDao.setRead(id, true) } just Runs + + repository.openMessage(id) + + // Style content must not leak and entities must be decoded (the derivation honors isHtml). + assertEquals("Tom & Jerry say \"hi\"", snippet.captured) + } + + @Test + fun `prefetchMessage leaves a plain-text body's literal angle brackets in the snippet`() = runTest { + val cache = Files.createTempDirectory("attach").toFile() + every { context.cacheDir } returns cache + val id = "acct:INBOX:21" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { imapClient.fetchBodyPeek(any(), "INBOX", "21") } returns + MessageContent("Reply to : 3 < 5", isHtml = false) + val snippet = slot() + coEvery { messageDao.updateBody(id, any(), any(), capture(snippet)) } just Runs + coEvery { attachmentDao.getForMessage(id) } returns emptyList() + + repository.prefetchMessage(id) + + // No tag stripping for plain text — only whitespace collapsing (and the length cap) applies. + assertEquals("Reply to : 3 < 5", snippet.captured) + } + @Test fun `archive moves messages to the account's archive folder and drops the local rows`() = runTest { val id = "acct:INBOX:5" diff --git a/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt index a5e67be..cecf675 100644 --- a/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt @@ -32,6 +32,22 @@ class HtmlToTextTest { assertFalse(converted.contains("alert")) } + @Test + fun `decodes numeric character references in decimal and hex`() { + assertEquals("It’s fine", HtmlToText.convert("It’s fine")) + assertEquals("It’s fine", HtmlToText.convert("It’s fine")) + } + + @Test + fun `decoding is single-pass so a decoded character is never re-decoded`() { + assertEquals("<b>", HtmlToText.convert("&lt;b&gt;")) + } + + @Test + fun `unknown or malformed references are left as-is`() { + assertEquals("&bogus; &#; stays", HtmlToText.convert("&bogus; &#; stays")) + } + @Test fun `collapses excess whitespace`() { val converted = HtmlToText.convert("

Hello there

\n\n\n

bye

")