diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/15.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/15.json new file mode 100644 index 0000000..d08a6ec --- /dev/null +++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/15.json @@ -0,0 +1,676 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "02427d9d822cbb18a9bcd0670c048585", + "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, `hierarchyDelimiter` TEXT, 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" + }, + { + "fieldPath": "hierarchyDelimiter", + "columnName": "hierarchyDelimiter", + "affinity": "TEXT" + } + ], + "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, '02427d9d822cbb18a9bcd0670c048585')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt index 076e821..6716829 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt @@ -73,6 +73,35 @@ class MigrationTest { db.close() } + /** v14 -> v15 (issue #66): `folders.hierarchyDelimiter` appears defaulting to NULL; data survives. */ + @Test + fun migrate14To15_addsNullHierarchyDelimiterToFolders() { + helper.createDatabase(TEST_DB, 14).apply { + insertAccount() + execSQL( + "INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder, specialUse) " + + "VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0, 0), " + + "('acct', 'Work.Reports', 'Reports', 'NORMAL', 1, 1, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(TEST_DB, 15, true, MIGRATION_14_15) + + db.query("SELECT fullName, hierarchyDelimiter FROM folders ORDER BY sortOrder").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("INBOX", c.getString(0)) + assertTrue("existing folders read a null delimiter until the next refresh", c.isNull(1)) + assertTrue(c.moveToNext()) + assertEquals("Work.Reports", c.getString(0)) + assertTrue(c.isNull(1)) + assertFalse("both pre-upgrade folder rows must survive, and nothing else", c.moveToNext()) + } + // The folders' account is untouched. + assertEquals(1, db.count("accounts")) + db.close() + } + /** * A gap in the chain (or a migration whose target schema was never committed) crashes upgrading * users, so fail fast with a readable message before replaying anything. @@ -228,6 +257,12 @@ class MigrationTest { assertTrue("folder cached at v8 must survive to the newest version", c.moveToFirst()) assertEquals(0, c.getInt(0)) } + // 14->15 adds a nullable hierarchyDelimiter; the folder cached at v8 predates it, so it reads + // null and the drawer infers the separator from the name until the next folder refresh. + query("SELECT hierarchyDelimiter FROM folders WHERE fullName = 'INBOX'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue("a folder cached before v15 must read a null delimiter", c.isNull(0)) + } } private fun SupportSQLiteDatabase.count(table: String): Int = query("SELECT COUNT(*) FROM $table").use { c -> diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index 7173749..24bf856 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -37,7 +37,7 @@ import org.libremail.data.local.entity.SignatureEntity SignatureEntity::class, BackfillProgressEntity::class, ], - version = 14, + version = 15, exportSchema = true, ) abstract class LibreMailDatabase : RoomDatabase() { diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index c05bbb9..f2c0171 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -158,6 +158,7 @@ internal fun FolderEntity.toDomain(): Folder = Folder( role = runCatching { FolderRole.valueOf(role) }.getOrDefault(FolderRole.NORMAL), selectable = selectable, specialUse = specialUse, + hierarchyDelimiter = hierarchyDelimiter?.firstOrNull(), ) internal fun FetchedFolder.toEntity(accountId: String, sortOrder: Int): FolderEntity = FolderEntity( @@ -168,6 +169,7 @@ internal fun FetchedFolder.toEntity(accountId: String, sortOrder: Int): FolderEn selectable = selectable, sortOrder = sortOrder, specialUse = FolderRole.isServerSpecial(attributes), + hierarchyDelimiter = hierarchyDelimiter?.toString(), ) internal fun FolderUnreadCount.toDomain(): UnreadCount = UnreadCount( diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index f4b3392..1b3a5f5 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -293,3 +293,18 @@ val MIGRATION_13_14 = object : Migration(13, 14) { } } } + +/** + * v14 -> v15: persist the server-reported IMAP hierarchy delimiter (issue #66; preserves existing + * data). Adds a nullable `hierarchyDelimiter` column to `folders` recording the separator character + * the server reported for the folder in its LIST response (e.g. "/" for Gmail, "." for some servers), + * so the drawer splits a folder's parent on the authoritative delimiter instead of re-inferring it + * from the name. Nullable with no SQL default (the MIGRATION_10_11 `bodyHtml` pattern) so existing + * rows read back null and fall back to the legacy inference until the next folder refresh backfills + * the real delimiter (`FolderDao.replaceForAccount` re-inserts every folder on each sync). + */ +val MIGRATION_14_15 = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `folders` ADD COLUMN `hierarchyDelimiter` TEXT") + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/FolderEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/FolderEntity.kt index 10ee226..0db0ed4 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/FolderEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/FolderEntity.kt @@ -20,4 +20,12 @@ data class FolderEntity( * when the drawer de-duplicates display labels. */ @ColumnInfo(defaultValue = "0") val specialUse: Boolean = false, + /** + * The hierarchy separator the server reported for this folder in its LIST response (e.g. "/" for + * Gmail, "." for some servers), stored as a one-character string. Null for folders cached before + * this column existed (legacy rows) or on a flat namespace; the drawer's parent-label logic then + * infers the separator from the folder name (issue #66). Nullable with no SQL default (the same + * pattern as the drafts `bodyHtml` column), so the next folder refresh backfills the real value. + */ + val hierarchyDelimiter: String? = null, ) diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index e6cebf4..792e3ef 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -18,6 +18,7 @@ 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_14_15 import org.libremail.data.local.MIGRATION_1_2 import org.libremail.data.local.MIGRATION_2_3 import org.libremail.data.local.MIGRATION_3_4 @@ -67,6 +68,7 @@ object DatabaseModule { MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, + MIGRATION_14_15, ) // No destructive fallback: the migration chain is complete, and silently dropping the // accounts/credentials/mail tables would lose stored secrets. A missing migration should diff --git a/app/src/main/kotlin/org/libremail/domain/model/Folder.kt b/app/src/main/kotlin/org/libremail/domain/model/Folder.kt index 3cbb648..348c99e 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Folder.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Folder.kt @@ -16,6 +16,12 @@ data class Folder( * a provider's built-in folder from a same-named user folder when de-duplicating labels. */ val specialUse: Boolean = false, + /** + * The server-reported IMAP hierarchy separator for this folder (e.g. '/' or '.'). Null when the + * server reported none or the folder was cached before the delimiter was persisted; label logic + * then infers the separator from the folder name (issue #66). + */ + val hierarchyDelimiter: Char? = null, ) /** diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 1744f2e..001409d 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -44,6 +44,13 @@ data class FetchedFolder( val attributes: List, /** False for \Noselect containers (e.g. Gmail's "[Gmail]" parent) that can't be opened. */ val selectable: Boolean, + /** + * The hierarchy separator the server reported for this folder in its LIST response (e.g. '/' for + * Gmail, '.' for some servers). Null when the server reported none (a flat namespace) or it could + * not be read. Persisted so a folder's parent is split on the authoritative delimiter rather than + * one re-inferred from the name (issue #66). + */ + val hierarchyDelimiter: Char? = null, ) /** A message header fetched from the server (no body — that arrives with the reader). */ @@ -90,12 +97,17 @@ class ImapClient @Inject constructor() { } else { emptyList() } - val separator = runCatching { folder.separator }.getOrDefault('/') + val reportedSeparator = runCatching { folder.separator }.getOrNull() + // Fall back to '/' only for splitting off the display name; the persisted delimiter + // stays null when the server reported none, so parentOf can tell "unknown" (infer) + // from a real separator. + val separator = reportedSeparator ?: '/' FetchedFolder( fullName = folder.fullName, displayName = folder.fullName.substringAfterLast(separator), attributes = attributes, selectable = attributes.none { it.equals("\\Noselect", ignoreCase = true) }, + hierarchyDelimiter = reportedSeparator?.takeUnless { it == Char.MIN_VALUE }, ) } // Some servers don't return INBOX from a wildcard LIST; guarantee it's always present. diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/FolderLabels.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/FolderLabels.kt index 94005e4..35f39ba 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/FolderLabels.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/FolderLabels.kt @@ -95,7 +95,7 @@ fun resolveDrawerLabels( * to the safety net as a self-referential "Sent [Sent]". */ private fun userFolderLabel(folder: Folder, base: String, parentPattern: String): String { - val parent = parentOf(folder.fullName, folder.displayName) + val parent = parentOf(folder) return when { parent != null -> parentPattern.format(base, parent) folder.displayName.equals(base, ignoreCase = true) -> base @@ -104,13 +104,26 @@ private fun userFolderLabel(folder: Folder, base: String, parentPattern: String) } /** - * The immediate parent segment of [fullName] (its location), or null when the folder is top-level. - * [displayName] is the leaf, so the character just before it in [fullName] is the server's hierarchy - * separator and the segment before that is the parent. + * The immediate parent segment of [folder]'s path (its location), or null when the folder is + * top-level. Splits [Folder.fullName] on the server-reported [Folder.hierarchyDelimiter] so a folder + * name that happens to contain a separator-looking character can't be mis-parented (issue #66). For + * legacy rows cached before the delimiter was persisted (a null delimiter) it falls back to inferring + * the separator as the character immediately before the [Folder.displayName] leaf. */ -private fun parentOf(fullName: String, displayName: String): String? { - if (fullName.length <= displayName.length || !fullName.endsWith(displayName)) return null - val separator = fullName[fullName.length - displayName.length - 1] - val parentPath = fullName.substring(0, fullName.length - displayName.length - 1) +private fun parentOf(folder: Folder): String? { + val separator = folder.hierarchyDelimiter + ?: inferSeparator(folder.fullName, folder.displayName) + ?: return null + val parentPath = folder.fullName.substringBeforeLast(separator, missingDelimiterValue = "") return parentPath.substringAfterLast(separator).ifEmpty { null } } + +/** + * Legacy fallback separator for rows without a persisted delimiter: the character just before the + * [displayName] leaf in [fullName], or null when [displayName] is not a suffix of [fullName] (then + * the folder is treated as top-level, exactly as before the delimiter was persisted). + */ +private fun inferSeparator(fullName: String, displayName: String): Char? { + if (fullName.length <= displayName.length || !fullName.endsWith(displayName)) return null + return fullName[fullName.length - displayName.length - 1] +} diff --git a/app/src/test/kotlin/org/libremail/data/local/FolderMapperTest.kt b/app/src/test/kotlin/org/libremail/data/local/FolderMapperTest.kt index 75fd901..7d12bc2 100644 --- a/app/src/test/kotlin/org/libremail/data/local/FolderMapperTest.kt +++ b/app/src/test/kotlin/org/libremail/data/local/FolderMapperTest.kt @@ -6,6 +6,7 @@ import org.libremail.domain.model.FolderRole import org.libremail.mail.FetchedFolder import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -77,4 +78,31 @@ class FolderMapperTest { assertTrue(entityFor(listOf("\\Drafts")).toDomain().specialUse) assertFalse(entityFor(emptyList(), displayName = "Receipts").toDomain().specialUse) } + + // Issue #66: the server-reported hierarchy delimiter is persisted (as a one-character string) and + // read back as the Char parentOf splits on, instead of being discarded after the LIST response. + @Test + fun `the server hierarchy delimiter round-trips through the entity`() { + val entity = FetchedFolder( + fullName = "Parent.Child", + displayName = "Child", + attributes = emptyList(), + selectable = true, + hierarchyDelimiter = '.', + ).toEntity(accountId = "acct", sortOrder = 0) + assertEquals(".", entity.hierarchyDelimiter) + assertEquals('.', entity.toDomain().hierarchyDelimiter) + } + + @Test + fun `an unknown hierarchy delimiter is persisted and read back as null`() { + val entity = FetchedFolder( + fullName = "INBOX", + displayName = "INBOX", + attributes = emptyList(), + selectable = true, + ).toEntity(accountId = "acct", sortOrder = 0) + assertNull(entity.hierarchyDelimiter) + assertNull(entity.toDomain().hierarchyDelimiter) + } } diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/FolderLabelsTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/FolderLabelsTest.kt index 0e876ac..4217b6f 100644 --- a/app/src/test/kotlin/org/libremail/ui/mailbox/FolderLabelsTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/FolderLabelsTest.kt @@ -20,7 +20,16 @@ class FolderLabelsTest { role: FolderRole = FolderRole.NORMAL, specialUse: Boolean = false, accountId: String = "acct", - ) = Folder(accountId, fullName, displayName, role, selectable = true, specialUse = specialUse) + hierarchyDelimiter: Char? = null, + ) = Folder( + accountId, + fullName, + displayName, + role, + selectable = true, + specialUse = specialUse, + hierarchyDelimiter = hierarchyDelimiter, + ) private fun account( email: String, @@ -114,6 +123,48 @@ class FolderLabelsTest { assertEquals("Reports (Work)", labels["Work/Reports"]) } + // Issue #66: parentOf splits on the server-reported delimiter that folder sync persists, rather + // than re-inferring it from the name. Here the server reported '.' and a decoded leaf that is NOT + // a substring-suffix of the raw path, so the persisted delimiter is the only thing that can locate + // the parent — the old endsWith-based inference would give up and return null. + @Test + fun `a persisted delimiter parents folders whose leaf is not a raw suffix of the path`() { + val folders = listOf( + folder("Team.Weekly", displayName = "Weekly Report", hierarchyDelimiter = '.'), + folder("Ops.Weekly", displayName = "Weekly Report", hierarchyDelimiter = '.'), + ) + val labels = resolve(folders, "example.org") + assertEquals("Weekly Report (Team)", labels["Team.Weekly"]) + assertEquals("Weekly Report (Ops)", labels["Ops.Weekly"]) + } + + // The contrast for the test above: with no persisted delimiter (a legacy row) the same two folders + // can't be parented by inference and collapse to the full-path safety net — proving the persisted + // delimiter, not name inference, is what disambiguates them. + @Test + fun `without a persisted delimiter the same folders fall back to the full-path safety net`() { + val folders = listOf( + folder("Team.Weekly", displayName = "Weekly Report", hierarchyDelimiter = null), + folder("Ops.Weekly", displayName = "Weekly Report", hierarchyDelimiter = null), + ) + val labels = resolve(folders, "example.org") + assertEquals("Weekly Report [Team.Weekly]", labels["Team.Weekly"]) + assertEquals("Weekly Report [Ops.Weekly]", labels["Ops.Weekly"]) + } + + // A null delimiter (rows written before the column existed) must keep working until the next + // folder refresh backfills it: parentOf falls back to inferring the separator from the name. + @Test + fun `a null delimiter infers the separator for legacy rows`() { + val folders = listOf( + folder("Work/Reports", hierarchyDelimiter = null), + folder("Personal/Reports", hierarchyDelimiter = null), + ) + val labels = resolve(folders, "example.org") + assertEquals("Reports (Work)", labels["Work/Reports"]) + assertEquals("Reports (Personal)", labels["Personal/Reports"]) + } + @Test fun `unique labels are left untouched`() { val folders = listOf(