Skip to content

Conversation

@fnune
Copy link

@fnune fnune commented Nov 16, 2025

Fixes #900

⚠️ I'm a Kotlin newbie. Posting for initial feedback on the approach before investing more time.

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 same archiveGranularity property. 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

  1. How can I test this properly? I'd love to add some integration or UI tests, would like to hear suggestions
  2. Should folder creation failures show user notifications, or is logging acceptable?
  3. Is PER_YEAR_ARCHIVE_FOLDERS the right default (keeping SINGLE_ARCHIVE_FOLDER for existing accounts)? This matches Desktop, but is a new default for Thunderbird Android

Screenshots

Screenshot_20251116_193637 Screenshot_20251116_193651 Screenshot_20251116_193921 Screenshot_20251116_193950 Screenshot_20251116_194018 Screenshot_20251116_194041 Screenshot_20251116_194109

fnune added 13 commits November 16, 2025 17:00
- 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)
Copy link
Author

@fnune fnune Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that after doing this it might be good to resync the folders (basically whatever the button on the screenshot does) because the new folder will not show up on the UI otherwise.

Image

<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>
Copy link
Author

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.

@fnune
Copy link
Author

fnune commented Dec 3, 2025

Hi @rafaeltonholo! Anything I can do to help get this chugging along? I keep refreshing this PR to see if anything's happened 😄

@rafaeltonholo
Copy link
Member

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

Comment on lines +18 to +46
@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)
}
}
}
Copy link
Member

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:

Suggested change
@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
}
}
}

Comment on lines +48 to +67
@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)
}
Copy link
Member

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:

Suggested change
@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
Copy link
Member

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.

Suggested change
val delimiter = FOLDER_DEFAULT_PATH_DELIMITER
val delimiter = account.folderPathDelimiter

Comment on lines +69 to +83
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
}
Copy link
Member

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:

Suggested change
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:

Suggested change
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).year

or in case you pick the extension function approach:

- val year = getYear(getMessageDate(message))
+ val year = message.messageDate.year

Pick the approach you feel better about.

@wmontwe wmontwe added merge block: soft freeze PR to main is blocked: risky code or feature flag enablement must wait until soft freeze lifts. and removed merge block: soft freeze PR to main is blocked: risky code or feature flag enablement must wait until soft freeze lifts. labels Dec 4, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support yearly/monthly archive folders

3 participants