-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(archive): add yearly/monthly archive folder options #10113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- Add ArchiveGranularity enum with DEFAULT and MIGRATION_DEFAULT - Update LegacyAccountDto with archiveGranularity property - Add database migration v28->v29 for backward compatibility - Add UI preference with Desktop-matching strings - Archive options disabled until archive folder selected
Added ArchiveFolderResolver skeleton with date-based routing stubs. Updated ArchiveOperations to group messages by destination folder. Messages are now batched by their resolved archive targets.
Resolver handles all destination logic internally, so the parameter is no longer needed in archiveMessages and archiveThreads methods.
Resolver now creates date-based subfolders on demand. Messages are routed to yearly (Archive/YYYY) or monthly (Archive/YYYY/MM) subfolders based on their internal/sent date and account granularity setting.
Use BackendStorage.updateFolders() instead of MessageStore.createFolders() to ensure folders are properly created both locally and remotely. This matches the pattern used in CreateArchiveFolder use case and ensures IMAP folder creation is queued for sync.
Add LegacyAccountDtoBackendStorageFactory mock to test to match updated MessagingController constructor signature.
Test coverage includes: - Single folder granularity (no folder creation) - Yearly folder creation and reuse - Monthly folder creation and reuse - Date handling (internal date vs sent date) - Error handling for folder creation failures - Month formatting with leading zeros
Applied Interface Segregation Principle by extracting two thin interfaces from thick dependencies: - FolderIdResolver (2 methods) replaces MessageStoreManager (60+ methods) - ArchiveFolderCreator (1 method) replaces BackendStorageFactory Added adapter implementations for production use and created simple fakes for testing. Tests now use 15-line fakes instead of 200+ line fakes that implemented entire thick interfaces.
Resolved detekt warnings by adding explicit locale to String.format, suppressing intentional generic exception catching, and suppressing early return count warnings for null safety checks.
Add getString/putString handlers in AccountSettingsDataStore to properly save and load archive_granularity preference changes.
| name = subfolderServerId, | ||
| type = LegacyFolderType.ARCHIVE, | ||
| ) | ||
| return folderCreator.createFolder(account, folderInfo) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| <string name="sent_folder_label">Sent folder</string> | ||
| <string name="trash_folder_label">Trash folder</string> | ||
| <string name="archive_folder_label">Archive folder</string> | ||
| <string name="archive_granularity_label">Archive options</string> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to match Thunderbird Desktop here but really there's only one option, so maybe this copy should be thought through once more. This is more stable if the area does get more features, and in that case "Archive options" would be fine, but currently it's a bit meh.
|
Hi @rafaeltonholo! Anything I can do to help get this chugging along? I keep refreshing this PR to see if anything's happened 😄 |
Hi there! I started the review, but I didn't have time to finish it yet, and I forgot to send the comments I have already made. Sorry about that! I'll try to finish the review this week, but here are the initial comments |
| @Suppress("ReturnCount") | ||
| fun resolveArchiveFolder( | ||
| account: LegacyAccountDto, | ||
| message: LocalMessage, | ||
| ): Long? { | ||
| val baseFolderId = account.archiveFolderId ?: return null | ||
|
|
||
| return when (account.archiveGranularity) { | ||
| ArchiveGranularity.SINGLE_ARCHIVE_FOLDER -> { | ||
| baseFolderId | ||
| } | ||
|
|
||
| ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS -> { | ||
| val year = getYear(getMessageDate(message)) | ||
| findOrCreateSubfolder(account, baseFolderId, year.toString()) | ||
| } | ||
|
|
||
| ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> { | ||
| val date = getMessageDate(message) | ||
| val year = getYear(date) | ||
| val month = String.format(Locale.ROOT, "%02d", getMonth(date)) | ||
|
|
||
| val yearFolderId = findOrCreateSubfolder(account, baseFolderId, year.toString()) | ||
| ?: return baseFolderId | ||
|
|
||
| findOrCreateSubfolder(account, yearFolderId, month) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please, try to not suppressing detekt issues that can be addressed. Here, you could use the ?.let {} scope function, together with the elvis operator (?:), to avoid the ReturnCount issue:
| @Suppress("ReturnCount") | |
| fun resolveArchiveFolder( | |
| account: LegacyAccountDto, | |
| message: LocalMessage, | |
| ): Long? { | |
| val baseFolderId = account.archiveFolderId ?: return null | |
| return when (account.archiveGranularity) { | |
| ArchiveGranularity.SINGLE_ARCHIVE_FOLDER -> { | |
| baseFolderId | |
| } | |
| ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS -> { | |
| val year = getYear(getMessageDate(message)) | |
| findOrCreateSubfolder(account, baseFolderId, year.toString()) | |
| } | |
| ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> { | |
| val date = getMessageDate(message) | |
| val year = getYear(date) | |
| val month = String.format(Locale.ROOT, "%02d", getMonth(date)) | |
| val yearFolderId = findOrCreateSubfolder(account, baseFolderId, year.toString()) | |
| ?: return baseFolderId | |
| findOrCreateSubfolder(account, yearFolderId, month) | |
| } | |
| } | |
| } | |
| fun resolveArchiveFolder( | |
| account: LegacyAccountDto, | |
| message: LocalMessage, | |
| ): Long? { | |
| val baseFolderId = account.archiveFolderId ?: return null | |
| return when (account.archiveGranularity) { | |
| ArchiveGranularity.SINGLE_ARCHIVE_FOLDER -> { | |
| baseFolderId | |
| } | |
| ArchiveGranularity.PER_YEAR_ARCHIVE_FOLDERS -> { | |
| val year = getYear(getMessageDate(message)) | |
| findOrCreateSubfolder(account, baseFolderId, year.toString()) | |
| } | |
| ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> { | |
| val date = getMessageDate(message) | |
| val year = getYear(date) | |
| val month = String.format(Locale.ROOT, "%02d", getMonth(date)) | |
| findOrCreateSubfolder( | |
| account, | |
| baseFolderId, | |
| year.toString(), | |
| )?.let { yearFolderId -> | |
| findOrCreateSubfolder(account, yearFolderId, month) | |
| } ?: baseFolderId | |
| } | |
| } | |
| } |
| @Suppress("ReturnCount") | ||
| private fun findOrCreateSubfolder( | ||
| account: LegacyAccountDto, | ||
| parentFolderId: Long, | ||
| subfolderName: String, | ||
| ): Long? { | ||
| val parentServerId = folderIdResolver.getFolderServerId(account, parentFolderId) ?: return null | ||
|
|
||
| val delimiter = FOLDER_DEFAULT_PATH_DELIMITER | ||
| val subfolderServerId = "$parentServerId$delimiter$subfolderName" | ||
|
|
||
| folderIdResolver.getFolderId(account, subfolderServerId)?.let { return it } | ||
|
|
||
| val folderInfo = FolderInfo( | ||
| serverId = subfolderServerId, | ||
| name = subfolderServerId, | ||
| type = LegacyFolderType.ARCHIVE, | ||
| ) | ||
| return folderCreator.createFolder(account, folderInfo) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could avoid the ReturnCount issue by using the following approach:
| @Suppress("ReturnCount") | |
| private fun findOrCreateSubfolder( | |
| account: LegacyAccountDto, | |
| parentFolderId: Long, | |
| subfolderName: String, | |
| ): Long? { | |
| val parentServerId = folderIdResolver.getFolderServerId(account, parentFolderId) ?: return null | |
| val delimiter = FOLDER_DEFAULT_PATH_DELIMITER | |
| val subfolderServerId = "$parentServerId$delimiter$subfolderName" | |
| folderIdResolver.getFolderId(account, subfolderServerId)?.let { return it } | |
| val folderInfo = FolderInfo( | |
| serverId = subfolderServerId, | |
| name = subfolderServerId, | |
| type = LegacyFolderType.ARCHIVE, | |
| ) | |
| return folderCreator.createFolder(account, folderInfo) | |
| } | |
| private fun findOrCreateSubfolder( | |
| account: LegacyAccountDto, | |
| parentFolderId: Long, | |
| subfolderName: String, | |
| ): Long? { | |
| val parentServerId = folderIdResolver.getFolderServerId(account, parentFolderId) ?: return null | |
| val delimiter = FOLDER_DEFAULT_PATH_DELIMITER | |
| val subfolderServerId = "$parentServerId$delimiter$subfolderName" | |
| val id = folderIdResolver.getFolderId(account, subfolderServerId) | |
| return if (id != null) { | |
| id | |
| } else { | |
| val folderInfo = FolderInfo( | |
| serverId = subfolderServerId, | |
| name = subfolderServerId, | |
| type = LegacyFolderType.ARCHIVE, | |
| ) | |
| folderCreator.createFolder(account, folderInfo) | |
| } | |
| } |
| ): Long? { | ||
| val parentServerId = folderIdResolver.getFolderServerId(account, parentFolderId) ?: return null | ||
|
|
||
| val delimiter = FOLDER_DEFAULT_PATH_DELIMITER |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't assume the default path delimiter will be the same for all accounts. Many email providers use different path delimiters. Use account.folderPathDelimiter instead.
| val delimiter = FOLDER_DEFAULT_PATH_DELIMITER | |
| val delimiter = account.folderPathDelimiter |
| private fun getMessageDate(message: LocalMessage): Date { | ||
| return message.internalDate ?: message.sentDate ?: Date() | ||
| } | ||
|
|
||
| private fun getYear(date: Date): Int { | ||
| val calendar = Calendar.getInstance() | ||
| calendar.time = date | ||
| return calendar.get(Calendar.YEAR) | ||
| } | ||
|
|
||
| private fun getMonth(date: Date): Int { | ||
| val calendar = Calendar.getInstance() | ||
| calendar.time = date | ||
| return calendar.get(Calendar.MONTH) + 1 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whenever possible, we should try to use kotlinx.datetime when dealing with dates. The reason is that it will make it easier to migrate, in a possible future, to KMP.
Here is how you could achieve the same, but using kotlinx.datetime:
| private fun getMessageDate(message: LocalMessage): Date { | |
| return message.internalDate ?: message.sentDate ?: Date() | |
| } | |
| private fun getYear(date: Date): Int { | |
| val calendar = Calendar.getInstance() | |
| calendar.time = date | |
| return calendar.get(Calendar.YEAR) | |
| } | |
| private fun getMonth(date: Date): Int { | |
| val calendar = Calendar.getInstance() | |
| calendar.time = date | |
| return calendar.get(Calendar.MONTH) + 1 | |
| } | |
| private fun getMessageDate(message: LocalMessage): LocalDate { | |
| val instant = (message.internalDate ?: message.sentDate)?.time?.let(Instant::fromEpochMilliseconds) | |
| val timeZone = TimeZone.currentSystemDefault() | |
| val now = Clock.System.now().toLocalDateTime(timeZone) | |
| return instant?.toLocalDateTime(timeZone)?.date ?: now.date | |
| } |
Additionally, you could use a more idiomatic way by using extension functions:
| private fun getMessageDate(message: LocalMessage): Date { | |
| return message.internalDate ?: message.sentDate ?: Date() | |
| } | |
| private fun getYear(date: Date): Int { | |
| val calendar = Calendar.getInstance() | |
| calendar.time = date | |
| return calendar.get(Calendar.YEAR) | |
| } | |
| private fun getMonth(date: Date): Int { | |
| val calendar = Calendar.getInstance() | |
| calendar.time = date | |
| return calendar.get(Calendar.MONTH) + 1 | |
| } | |
| private val LocalMessage.messageDate: LocalDate | |
| get() { | |
| val instant = (message.internalDate ?: message.sentDate)?.time?.let(Instant::fromEpochMilliseconds) | |
| val timeZone = TimeZone.currentSystemDefault() | |
| val now = Clock.System.now().toLocalDateTime(timeZone) | |
| return instant?.toLocalDateTime(timeZone)?.date ?: now.date | |
| } |
Then, on line 31, you can just use .year to retrieve the year from the LocalDate:
- val year = getYear(getMessageDate(message))
+ val year = getMessageDate(message).yearor in case you pick the extension function approach:
- val year = getYear(getMessageDate(message))
+ val year = message.messageDate.yearPick the approach you feel better about.

Fixes #900
Adds yearly and monthly archive folder organization matching Desktop's behavior. Eliminates the need to manually switch archive folders each year. Does this with an "Archive options" preference with three modes: Single folder, Yearly folders (
Archive/2025), or Monthly folders (Archive/2025/03). Matches Desktop's two-setting approach and uses the samearchiveGranularityproperty. Year/month subfolders are created automatically based on message date. Existing accounts default to single folder mode for backward compatibility.Design decisions
Two settings: matches Desktop's "which folder?" + "how to organize?" rather than a flat list of "Archive-Yearly", "CustomFolder-Monthly", etc. Avoids combinatorial explosion and works with any base folder.
Automatic subfolder creation: year/month folders are created automatically based on granularity setting, not manually selected. This avoids yearly updates and matches Desktop behavior.
Defaults: new accounts default to yearly (matching Desktop). Existing accounts migrated to single folder to preserve current behavior.
Potential follow-up
Issue #905 folder structure preservation, automatic yearly (or yearly + monthly) detection, translations...
Questions for reviewers
PER_YEAR_ARCHIVE_FOLDERSthe right default (keepingSINGLE_ARCHIVE_FOLDERfor existing accounts)? This matches Desktop, but is a new default for Thunderbird AndroidScreenshots