Merge branch 'main' into feat-screen-unlock

This commit is contained in:
2026-07-01 23:40:23 -05:00
10 changed files with 885 additions and 17 deletions
@@ -0,0 +1,671 @@
{
"formatVersion": 1,
"database": {
"version": 14,
"identityHash": "e4f7ef1e0d780324d6eec3efced768ae",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "retentionCount",
"columnName": "retentionCount",
"affinity": "INTEGER"
},
{
"fieldPath": "retentionMonths",
"columnName": "retentionMonths",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentHtml",
"columnName": "contentHtml",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e4f7ef1e0d780324d6eec3efced768ae')"
]
}
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data
import org.libremail.mail.HtmlToText
/**
* Derives the one-line mailbox preview snippet that is persisted alongside a fetched body.
*
* HTML bodies are reduced to readable text via [HtmlToText] (script/style *content* dropped, tags
* stripped, entities decoded) before the whitespace collapsing. Plain-text bodies get no markup
* handling at all — literal `<`/`>` characters survive — only whitespace collapsing. Both paths end
* with the same [MAX_LENGTH] cap.
*
* Derivation runs once, when a body is fetched and cached (plus the one-off migration backfill) —
* never per mailbox-list row.
*/
object Snippet {
const val MAX_LENGTH = 140
private val WHITESPACE = Regex("\\s+")
fun of(body: String, isHtml: Boolean): String {
val text = if (isHtml) HtmlToText.convert(body) else body
return text.replace(WHITESPACE, " ").trim().take(MAX_LENGTH)
}
}
@@ -37,7 +37,7 @@ import org.libremail.data.local.entity.SignatureEntity
SignatureEntity::class,
BackfillProgressEntity::class,
],
version = 13,
version = 14,
exportSchema = true,
)
abstract class LibreMailDatabase : RoomDatabase() {
@@ -3,6 +3,7 @@ package org.libremail.data.local
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.libremail.data.Snippet
/** v1 -> v2: add the encrypted-credentials table (preserves existing accounts/messages). */
val MIGRATION_1_2 = object : Migration(1, 2) {
@@ -267,3 +268,28 @@ val MIGRATION_12_13 = object : Migration(12, 13) {
)
}
}
/**
* v13 -> v14: data-only, no schema change (preserves existing data). Re-derives the persisted
* `snippet` of every message with a cached body using [Snippet.of], which — unlike the derivation
* it replaces — respects `isHtml`: HTML rows lose leaked `<style>`/`<script>` text and literal
* entities, plain-text rows get back any `<...>` text that was wrongly stripped as markup. A
* snippet is only derived when a body is first fetched, so without this pass existing rows would
* keep their broken snippets forever. Snippets are computed while the cursor streams (bodies are
* never all held in memory) and the small id→snippet batch is applied after it closes.
*/
val MIGRATION_13_14 = object : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
val updates = mutableListOf<Pair<String, String>>()
db.query("SELECT `id`, `body`, `isHtml` FROM `messages` WHERE `bodyFetched` = 1").use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val snippet = Snippet.of(body = cursor.getString(1), isHtml = cursor.getInt(2) != 0)
updates += id to snippet
}
}
updates.forEach { (id, snippet) ->
db.execSQL("UPDATE `messages` SET `snippet` = ? WHERE `id` = ?", arrayOf(snippet, id))
}
}
}
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.ReplyBuilder
import org.libremail.data.SignatureBlock
import org.libremail.data.Snippet
import org.libremail.data.attachmentCacheDir
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
@@ -84,7 +85,7 @@ class MailRepositoryImpl @Inject constructor(
val params = connectionFactory.imapParamsFor(account)
if (!entity.bodyFetched) {
val content = imapClient.fetchBodyMarkingSeen(params, entity.folder, uidOf(id))
messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body))
messageDao.updateBody(id, content.body, content.isHtml, Snippet.of(content.body, content.isHtml))
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
messageDao.setRead(id, true)
} else if (!entity.isRead) {
@@ -129,7 +130,7 @@ class MailRepositoryImpl @Inject constructor(
// Cache the body (peek, so prefetching never marks the message read) and its attachment metadata.
if (!entity.bodyFetched) {
val content = imapClient.fetchBodyPeek(params, entity.folder, uidOf(messageId))
messageDao.updateBody(messageId, content.body, content.isHtml, snippetOf(content.body))
messageDao.updateBody(messageId, content.body, content.isHtml, Snippet.of(content.body, content.isHtml))
attachmentDao.replaceForMessage(messageId, content.attachments.map { it.toEntity(messageId) })
}
// Auto-download every attachment's bytes into the persistent per-part cache (skips ones present).
@@ -371,10 +372,6 @@ class MailRepositoryImpl @Inject constructor(
}
private const val SEARCH_LIMIT = 50
private const val SNIPPET_LENGTH = 140
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
private fun uidOf(id: String): String = id.substringAfterLast(':')
private fun snippetOf(body: String): String =
body.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim().take(SNIPPET_LENGTH)
@@ -17,6 +17,7 @@ import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.MIGRATION_10_11
import org.libremail.data.local.MIGRATION_11_12
import org.libremail.data.local.MIGRATION_12_13
import org.libremail.data.local.MIGRATION_13_14
import org.libremail.data.local.MIGRATION_1_2
import org.libremail.data.local.MIGRATION_2_3
import org.libremail.data.local.MIGRATION_3_4
@@ -65,6 +66,7 @@ object DatabaseModule {
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
MIGRATION_13_14,
)
// No destructive fallback: the migration chain is complete, and silently dropping the
// accounts/credentials/mail tables would lose stored secrets. A missing migration should
@@ -32,14 +32,38 @@ object HtmlToText {
return s.trim()
}
private fun decodeEntities(s: String): String = s
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&mdash;", "—")
.replace("&ndash;", "–")
.replace("&amp;", "&")
private val ENTITY = Regex("&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]*));")
private val NAMED_ENTITIES = mapOf(
"nbsp" to " ",
"lt" to "<",
"gt" to ">",
"quot" to "\"",
"apos" to "'",
"mdash" to "—",
"ndash" to "–",
"amp" to "&",
)
/**
* Decodes named and numeric (`&#8217;` / `&#x2019;`) character references in a single pass, so a
* produced character is never re-parsed as the start of another entity (`&amp;lt;` → `&lt;`).
* Unknown names and out-of-range code points are left as-is.
*/
private fun decodeEntities(s: String): String = ENTITY.replace(s) { match ->
val (decimal, hex, name) = match.destructured
when {
decimal.isNotEmpty() -> decimal.toIntOrNull()?.toValidChars() ?: match.value
hex.isNotEmpty() -> hex.toIntOrNull(HEX_RADIX)?.toValidChars() ?: match.value
else -> NAMED_ENTITIES[name] ?: match.value
}
}
/** The code point as a string, or null when it is not a valid scalar value. */
private fun Int.toValidChars(): String? = takeIf { it in 1..MAX_CODE_POINT && it !in SURROGATES }
?.let { String(Character.toChars(it)) }
private const val HEX_RADIX = 16
private const val MAX_CODE_POINT = 0x10FFFF
private val SURROGATES = 0xD800..0xDFFF
}
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data
import org.junit.Test
import kotlin.test.assertEquals
class SnippetTest {
@Test
fun `html tags are stripped and block breaks collapse to single spaces`() {
assertEquals("Hello world bye", Snippet.of("<div><p>Hello <b>world</b></p><p>bye</p></div>", isHtml = true))
}
@Test
fun `html style and script content never leaks into the snippet`() {
val snippet = Snippet.of(
"<style>.body{color:#f00}</style><script>alert('x')</script><p>Visible</p>",
isHtml = true,
)
assertEquals("Visible", snippet)
}
@Test
fun `html entities are decoded including numeric references`() {
assertEquals(
"Tom & Jerry — \"friends\" don’t fight",
Snippet.of("Tom &amp; Jerry &mdash; &quot;friends&quot; don&#8217;t fight", isHtml = true),
)
}
@Test
fun `html nbsp becomes a plain space and runs of whitespace collapse`() {
assertEquals("a b c", Snippet.of("a&nbsp;b\n\t c", isHtml = true))
}
@Test
fun `html snippet is capped after markup is removed`() {
val body = "<p>" + "x".repeat(500) + "</p>"
val snippet = Snippet.of(body, isHtml = true)
assertEquals("x".repeat(Snippet.MAX_LENGTH), snippet)
}
@Test
fun `plain text keeps literal angle brackets`() {
assertEquals(
"From <ada@example.org>: 3 < 5 and x > y",
Snippet.of("From <ada@example.org>: 3 < 5 and x > y", isHtml = false),
)
}
@Test
fun `plain text is not entity-decoded`() {
assertEquals("Fish &amp; chips", Snippet.of("Fish &amp; 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))
}
}
@@ -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(
"<style>.x{color:red}</style><p>Tom &amp; Jerry say &quot;hi&quot;</p>",
isHtml = true,
)
val snippet = slot<String>()
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 <ada@example.org>: 3 < 5", isHtml = false)
val snippet = slot<String>()
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 <ada@example.org>: 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"
@@ -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&#8217;s fine"))
assertEquals("It’s fine", HtmlToText.convert("It&#x2019;s fine"))
}
@Test
fun `decoding is single-pass so a decoded character is never re-decoded`() {
assertEquals("&lt;b&gt;", HtmlToText.convert("&amp;lt;b&amp;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("<p>Hello there</p>\n\n\n<p>bye</p>")