Merge pull request #122 from JMR-dev/feat-persist-imap-delimiter
feat(folders): persist the server-reported IMAP hierarchy delimiter
This commit was merged in pull request #122.
This commit is contained in:
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,13 @@ data class FetchedFolder(
|
||||
val attributes: List<String>,
|
||||
/** 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.
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user