Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
af15d2b
feat(settings): add archive granularity options
fnune Nov 16, 2025
8114d09
feat(archive): integrate ArchiveFolderResolver
fnune Nov 16, 2025
d09d6f6
refactor(archive): remove redundant archiveFolderId parameter
fnune Nov 16, 2025
b6fa2c0
feat(archive): implement date-based folder resolution
fnune Nov 16, 2025
a346392
refactor(archive): use BackendStorage for folder creation
fnune Nov 16, 2025
a8a1981
test: update MessagingControllerTest for new constructor
fnune Nov 16, 2025
d57b9e7
test: add unit tests for ArchiveFolderResolver
fnune Nov 16, 2025
c4153b5
refactor: extract thin interfaces for ArchiveFolderResolver
fnune Nov 16, 2025
b898228
chore: fix detekt static analysis issues
fnune Nov 16, 2025
3e9ca27
test: rename resolver to testSubject in ArchiveFolderResolverTest
fnune Nov 16, 2025
55f1e0c
chore: remove unnecessary comments
fnune Nov 16, 2025
3d9a71f
fix: connect archive granularity UI to account settings
fnune Nov 16, 2025
1f8f03e
test: add StorageMigrationTo29Test for archiveGranularity migration
fnune Nov 16, 2025
129b0f7
refactor: rebase and attend to review changes
fnune Jan 19, 2026
d4fd284
chore: revert unwanted changes
rafaeltonholo Apr 16, 2026
99bd645
chore: remove ExperimentalTime usage in ArchiveFolderResolver
rafaeltonholo Apr 16, 2026
0e37089
fix: add missing folder path delimiter in the folder creation
rafaeltonholo Apr 16, 2026
7c83cc1
style: fix import order
rafaeltonholo Apr 16, 2026
1bbf20a
refactor: replace `LegacyAccountDtoBackendStorageFactory` with `Backe…
rafaeltonholo Apr 16, 2026
56d57bd
feat(ui): toggle archive granularity based on folder selection
rafaeltonholo Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.storage.profile.AvatarDto
import net.thunderbird.feature.account.storage.profile.AvatarTypeDto
import net.thunderbird.feature.mail.account.api.BaseAccount
import net.thunderbird.feature.mail.folder.api.ArchiveGranularity
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.notification.NotificationSettings
Expand Down Expand Up @@ -199,6 +200,9 @@ open class LegacyAccountDto(
@get:Synchronized
var archiveFolderSelection = SpecialFolderSelection.AUTOMATIC

@get:Synchronized
var archiveGranularity = ArchiveGranularity.DEFAULT

@get:Synchronized
var spamFolderSelection = SpecialFolderSelection.AUTOMATIC

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import net.thunderbird.core.preference.storage.StorageEditor
import net.thunderbird.core.preference.storage.getEnumOrDefault
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.storage.legacy.serializer.ServerSettingsDtoSerializer
import net.thunderbird.feature.mail.folder.api.ArchiveGranularity
import net.thunderbird.feature.mail.folder.api.FOLDER_DEFAULT_PATH_DELIMITER
import net.thunderbird.feature.mail.folder.api.SpecialFolderSelection
import net.thunderbird.feature.notification.NotificationLight
Expand Down Expand Up @@ -115,6 +116,12 @@ class LegacyAccountStorageHandler(
)
setArchiveFolderId(archiveFolderId, archiveFolderSelection)

archiveGranularity = getEnumStringPref(
storage,
keyGen.create(ARCHIVE_GRANULARITY_KEY),
ArchiveGranularity.DEFAULT,
)

val spamFolderId = storage.getStringOrNull(keyGen.create("spamFolderId"))?.toLongOrNull()
val spamFolderSelection = getEnumStringPref<SpecialFolderSelection>(
storage,
Expand Down Expand Up @@ -354,6 +361,7 @@ class LegacyAccountStorageHandler(
editor.putString(keyGen.create("archiveFolderId"), archiveFolderId?.toString())
editor.putString(keyGen.create("spamFolderId"), spamFolderId?.toString())
editor.putString(keyGen.create("archiveFolderSelection"), archiveFolderSelection.name)
editor.putString(keyGen.create(ARCHIVE_GRANULARITY_KEY), archiveGranularity.name)
editor.putString(keyGen.create("draftsFolderSelection"), draftsFolderSelection.name)
editor.putString(keyGen.create("sentFolderSelection"), sentFolderSelection.name)
editor.putString(keyGen.create("spamFolderSelection"), spamFolderSelection.name)
Expand Down Expand Up @@ -470,6 +478,7 @@ class LegacyAccountStorageHandler(
editor.remove(keyGen.create("archiveFolderName"))
editor.remove(keyGen.create("spamFolderName"))
editor.remove(keyGen.create("archiveFolderSelection"))
editor.remove(keyGen.create(ARCHIVE_GRANULARITY_KEY))
editor.remove(keyGen.create("draftsFolderSelection"))
editor.remove(keyGen.create("sentFolderSelection"))
editor.remove(keyGen.create("spamFolderSelection"))
Expand Down Expand Up @@ -607,5 +616,6 @@ class LegacyAccountStorageHandler(
const val IDENTITY_DESCRIPTION_KEY = "description"

const val FOLDER_PATH_DELIMITER_KEY = "folderPathDelimiter"
const val ARCHIVE_GRANULARITY_KEY = "archiveGranularity"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package net.thunderbird.feature.mail.folder.api

enum class ArchiveGranularity {
SINGLE_ARCHIVE_FOLDER,
PER_YEAR_ARCHIVE_FOLDERS,
PER_MONTH_ARCHIVE_FOLDERS,
;

companion object {
/**
* Default archive granularity for new accounts.
* Matches Thunderbird Desktop default (value 1 = yearly).
*/
val DEFAULT = PER_YEAR_ARCHIVE_FOLDERS

/**
* Default for existing accounts during migration.
* Maintains backward compatibility with current single folder behavior.
*/
val MIGRATION_DEFAULT = SINGLE_ARCHIVE_FOLDER
}
}
1 change: 1 addition & 0 deletions legacy/core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.moshi)
implementation(libs.timber)
implementation(libs.kotlinx.datetime)
implementation(libs.mime4j.core)
implementation(libs.mime4j.dom)
implementation(libs.uri)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.fsck.k9.controller

import com.fsck.k9.backend.api.FolderInfo
import net.thunderbird.core.android.account.LegacyAccountDto

internal interface ArchiveFolderCreator {
fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.fsck.k9.controller

import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.mailstore.LocalMessage
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import net.thunderbird.core.android.account.LegacyAccountDto
import net.thunderbird.feature.mail.folder.api.ArchiveGranularity
import com.fsck.k9.mail.FolderType as LegacyFolderType

internal class ArchiveFolderResolver(
private val folderIdResolver: FolderIdResolver,
private val folderCreator: ArchiveFolderCreator,
) {

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 = message.messageDate.year
findOrCreateSubfolder(account, baseFolderId, year.toString()) ?: baseFolderId
}

ArchiveGranularity.PER_MONTH_ARCHIVE_FOLDERS -> {
val date = message.messageDate
val year = date.year
val month = String.format(Locale.ROOT, "%02d", date.monthNumber)

findOrCreateSubfolder(account, baseFolderId, year.toString())?.let { yearFolderId ->
findOrCreateSubfolder(account, yearFolderId, month)
} ?: baseFolderId
}
}
}

private fun findOrCreateSubfolder(
account: LegacyAccountDto,
parentFolderId: Long,
subfolderName: String,
): Long? {
val parentServerId = folderIdResolver.getFolderServerId(account, parentFolderId) ?: return null

val delimiter = account.folderPathDelimiter
val subfolderServerId = "$parentServerId$delimiter$subfolderName"

val existingId = folderIdResolver.getFolderId(account, subfolderServerId)
return if (existingId != null) {
existingId
} else {
val folderInfo = FolderInfo(
serverId = subfolderServerId,
name = subfolderServerId,
type = LegacyFolderType.ARCHIVE,
folderPathDelimiter = delimiter,
)
folderCreator.createFolder(account, folderInfo)
}
}

private val LocalMessage.messageDate: LocalDate
get() {
val epochMillis = (internalDate ?: sentDate)?.time
val timeZone = TimeZone.currentSystemDefault()
val instant = epochMillis?.let { Instant.fromEpochMilliseconds(it) }
?: Clock.System.now()
return instant.toLocalDateTime(timeZone).date
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ import net.thunderbird.core.logging.legacy.Log
internal class ArchiveOperations(
private val messagingController: MessagingController,
private val featureFlagProvider: FeatureFlagProvider,
private val archiveFolderResolver: ArchiveFolderResolver,
) {
fun archiveThreads(messages: List<MessageReference>) {
archiveByFolder("archiveThreads", messages) { account, folderId, messagesInFolder, archiveFolderId ->
archiveThreads(account, folderId, messagesInFolder, archiveFolderId)
archiveByFolder("archiveThreads", messages) { account, folderId, messagesInFolder ->
archiveThreads(account, folderId, messagesInFolder)
}
}

fun archiveMessages(messages: List<MessageReference>) {
archiveByFolder("archiveMessages", messages) { account, folderId, messagesInFolder, archiveFolderId ->
archiveMessages(account, folderId, messagesInFolder, archiveFolderId)
archiveByFolder("archiveMessages", messages) { account, folderId, messagesInFolder ->
archiveMessages(account, folderId, messagesInFolder)
}
}

Expand All @@ -37,7 +38,6 @@ internal class ArchiveOperations(
account: LegacyAccountDto,
folderId: Long,
messagesInFolder: List<LocalMessage>,
archiveFolderId: Long,
) -> Unit,
) {
actOnMessagesGroupedByAccountAndFolder(messages) { account, messageFolder, messagesInFolder ->
Expand All @@ -54,7 +54,7 @@ internal class ArchiveOperations(
else -> {
messagingController.suppressMessages(account, messagesInFolder)
messagingController.putBackground(description, null) {
action(account, sourceFolderId, messagesInFolder, archiveFolderId)
action(account, sourceFolderId, messagesInFolder)
}
}
}
Expand All @@ -65,30 +65,37 @@ internal class ArchiveOperations(
account: LegacyAccountDto,
sourceFolderId: Long,
messages: List<LocalMessage>,
archiveFolderId: Long,
) {
val messagesInThreads = messagingController.collectMessagesInThreads(account, messages)
archiveMessages(account, sourceFolderId, messagesInThreads, archiveFolderId)
archiveMessages(account, sourceFolderId, messagesInThreads)
}

private fun archiveMessages(
account: LegacyAccountDto,
sourceFolderId: Long,
messages: List<LocalMessage>,
archiveFolderId: Long,
) {
val messagesByDestination = messages.groupBy { message ->
archiveFolderResolver.resolveArchiveFolder(account, message)
}

val operation = featureFlagProvider.provide("archive_marks_as_read".toFeatureFlagKey())
.whenEnabledOrNot(
onEnabled = { MoveOrCopyFlavor.MOVE_AND_MARK_AS_READ },
onDisabledOrUnavailable = { MoveOrCopyFlavor.MOVE },
)
messagingController.moveOrCopyMessageSynchronous(
account,
sourceFolderId,
messages,
archiveFolderId,
operation,
)

for ((destinationFolderId, messagesForFolder) in messagesByDestination) {
if (destinationFolderId != null) {
messagingController.moveOrCopyMessageSynchronous(
account,
sourceFolderId,
messagesForFolder,
destinationFolderId,
operation,
)
}
}
}

private fun actOnMessagesGroupedByAccountAndFolder(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.fsck.k9.controller

import com.fsck.k9.backend.api.FolderInfo
import com.fsck.k9.backend.api.createFolder
import com.fsck.k9.backend.api.updateFolders
import net.thunderbird.backend.api.BackendStorageFactory
import net.thunderbird.core.android.account.LegacyAccountDto
import net.thunderbird.core.logging.legacy.Log

internal class BackendStorageArchiveFolderCreator(
private val backendStorageFactory: BackendStorageFactory,
) : ArchiveFolderCreator {
@Suppress("TooGenericExceptionCaught")
override fun createFolder(account: LegacyAccountDto, folderInfo: FolderInfo): Long? {
return try {
val backendStorage = backendStorageFactory.createBackendStorage(account.id)
backendStorage.updateFolders {
createFolder(folderInfo)
}
} catch (e: Exception) {
Log.e(e, "Failed to create archive subfolder: ${folderInfo.serverId}")
null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.fsck.k9.controller

import net.thunderbird.core.android.account.LegacyAccountDto

internal interface FolderIdResolver {
fun getFolderServerId(account: LegacyAccountDto, folderId: Long): String?
fun getFolderId(account: LegacyAccountDto, folderServerId: String): Long?
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.fsck.k9.mailstore.SaveMessageDataCreator
import com.fsck.k9.mailstore.SpecialLocalFoldersCreator
import com.fsck.k9.notification.NotificationController
import com.fsck.k9.notification.NotificationStrategy
import net.thunderbird.backend.api.BackendStorageFactory
import net.thunderbird.core.featureflag.FeatureFlagProvider
import net.thunderbird.core.logging.Logger
import net.thunderbird.feature.mail.folder.api.OutboxFolderManager
Expand All @@ -21,6 +22,7 @@ import org.koin.dsl.binds
import org.koin.dsl.module

val controllerModule = module {
single<FolderIdResolver> { MessageStoreFolderIdResolver(messageStoreManager = get()) }
single {
MessagingController(
get<Context>(),
Expand All @@ -38,6 +40,8 @@ val controllerModule = module {
get<Logger>(named("syncDebug")),
get<NotificationManager>(),
get<OutboxFolderManager>(),
get<BackendStorageFactory>(),
get<FolderIdResolver>(),
)
} binds arrayOf(MessagingControllerRegistry::class)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.fsck.k9.controller

import app.k9mail.legacy.mailstore.MessageStoreManager
import net.thunderbird.core.android.account.LegacyAccountDto

internal class MessageStoreFolderIdResolver(
private val messageStoreManager: MessageStoreManager,
) : FolderIdResolver {
override fun getFolderServerId(account: LegacyAccountDto, folderId: Long): String? {
return messageStoreManager.getMessageStore(account).getFolderServerId(folderId)
}

override fun getFolderId(account: LegacyAccountDto, folderServerId: String): Long? {
return messageStoreManager.getMessageStore(account).getFolderId(folderServerId)
}
}
Loading
Loading