Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ captures/

# Screenshots
adb-screenshots/
# Extracted eml files
eml-files/
3 changes: 3 additions & 0 deletions app-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ dependencies {
implementation(projects.feature.notification.impl)
implementation(projects.feature.widget.messageList)

implementation(projects.feature.mail.message.export.api)
implementation(projects.feature.mail.message.export.implEml)

implementation(projects.mail.protocols.imap)
implementation(projects.backend.imap)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package net.thunderbird.app.common.feature.mail

import com.fsck.k9.mailstore.DefaultSpecialFolderUpdater
import com.fsck.k9.mailstore.LegacyAccountDtoSpecialFolderUpdaterFactory
import net.thunderbird.app.common.feature.mail.message.mailMessageModule
import net.thunderbird.backend.api.BackendFactory
import net.thunderbird.backend.api.BackendStorageFactory
import net.thunderbird.backend.api.folder.RemoteFolderCreator
Expand All @@ -13,6 +14,8 @@ import org.koin.dsl.module

internal val appCommonFeatureMailModule = module {

includes(mailMessageModule)

single<BackendStorageFactory<BaseAccount>> {
BaseAccountBackendStorageFactory(
legacyFactory = get(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package net.thunderbird.app.common.feature.mail.message

import net.thunderbird.feature.mail.message.export.DefaultMessageFileNameSuggester
import net.thunderbird.feature.mail.message.export.MessageExporter
import net.thunderbird.feature.mail.message.export.MessageFileNameSuggester
import net.thunderbird.feature.mail.message.export.eml.EmlMessageExporter
import org.koin.dsl.module

internal val mailMessageModule = module {
single<MessageFileNameSuggester> { DefaultMessageFileNameSuggester() }

single<MessageExporter> {
EmlMessageExporter(
fileManager = get(),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.k9mail.featureflag

import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
import com.fsck.k9.ui.messageview.MessageViewFeatureFlags
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlagFactory
import net.thunderbird.core.featureflag.FeatureFlagKey
Expand All @@ -18,6 +19,7 @@ class K9FeatureFlagFactory : FeatureFlagFactory {
FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = false),
FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = false),
FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
FeatureFlag(MessageViewFeatureFlags.ActionExportEml, enabled = true),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.k9mail.featureflag

import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
import com.fsck.k9.ui.messageview.MessageViewFeatureFlags
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlagFactory
import net.thunderbird.core.featureflag.FeatureFlagKey
Expand All @@ -22,6 +23,7 @@ class K9FeatureFlagFactory : FeatureFlagFactory {
FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = false),
FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = false),
FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
FeatureFlag(MessageViewFeatureFlags.ActionExportEml, enabled = false),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.thunderbird.android.featureflag

import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
import com.fsck.k9.ui.messageview.MessageViewFeatureFlags
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlagFactory
import net.thunderbird.core.featureflag.FeatureFlagKey
Expand All @@ -21,6 +22,7 @@ class TbFeatureFlagFactory : FeatureFlagFactory {
FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = false),
FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = false),
FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
FeatureFlag(MessageViewFeatureFlags.ActionExportEml, enabled = false),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.thunderbird.android.featureflag

import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
import com.fsck.k9.ui.messageview.MessageViewFeatureFlags
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlagFactory
import net.thunderbird.core.featureflag.FeatureFlagKey
Expand All @@ -21,6 +22,7 @@ class TbFeatureFlagFactory : FeatureFlagFactory {
FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = true),
FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = false),
FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
FeatureFlag(MessageViewFeatureFlags.ActionExportEml, enabled = true),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.thunderbird.android.featureflag

import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
import com.fsck.k9.ui.messageview.MessageViewFeatureFlags
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlagFactory
import net.thunderbird.core.featureflag.FeatureFlagKey
Expand All @@ -21,6 +22,7 @@ class TbFeatureFlagFactory : FeatureFlagFactory {
FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = true),
FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = true),
FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
FeatureFlag(MessageViewFeatureFlags.ActionExportEml, enabled = true),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.thunderbird.android.featureflag

import com.fsck.k9.ui.messagelist.MessageListFeatureFlags
import com.fsck.k9.ui.messageview.MessageViewFeatureFlags
import net.thunderbird.core.featureflag.FeatureFlag
import net.thunderbird.core.featureflag.FeatureFlagFactory
import net.thunderbird.core.featureflag.FeatureFlagKey
Expand All @@ -21,6 +22,7 @@ class TbFeatureFlagFactory : FeatureFlagFactory {
FeatureFlag(FeatureFlagKey.DisplayInAppNotifications, enabled = false),
FeatureFlag(FeatureFlagKey.UseNotificationSenderForSystemNotifications, enabled = false),
FeatureFlag(MessageListFeatureFlags.UseComposeForMessageListItems, enabled = false),
FeatureFlag(MessageViewFeatureFlags.ActionExportEml, enabled = false),
)
}
}
16 changes: 16 additions & 0 deletions feature/mail/message/export/api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}

kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.uri)
implementation(projects.core.outcome)
}
}
}

android {
namespace = "net.thunderbird.feature.mail.message.export"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package net.thunderbird.feature.mail.message.export

import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char

/**
* Default, format-agnostic implementation for [MessageFileNameSuggester].
*/
class DefaultMessageFileNameSuggester : MessageFileNameSuggester {
override fun suggestFileName(subject: String, sentDateTime: LocalDateTime, extension: String): String {
val normalizedSubject = subject.trim()
.lowercase()
.replace(nonAlphanumericRegex, "-")
.replace(consecutiveDashesRegex, "-")
.trim('-')
.ifEmpty { "message" }

return "${fileNameDateFormat.format(sentDateTime)}_$normalizedSubject.$extension"
}

private companion object {
val fileNameDateFormat = LocalDateTime.Format {
year()
char('-')
monthNumber()
char('-')
day()
char('_')
hour()
char('-')
minute()
}

val nonAlphanumericRegex = Regex("[^a-z0-9-]+")
val consecutiveDashesRegex = Regex("-+")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package net.thunderbird.feature.mail.message.export

import com.eygraber.uri.Uri

/**
* Error type for message export failures.
*
* Keep this minimal and format-agnostic. Additional cases can be added later if needed.
*/
sealed interface MessageExportError {
/** Source or destination couldn't be opened or accessed. */
data class Unavailable(val uri: Uri, val message: String? = null) : MessageExportError

/** Generic I/O error while copying/exporting. */
data class Io(val message: String? = null) : MessageExportError

/** Fallback when the error type can't be determined. */
data class Unknown(val message: String? = null) : MessageExportError
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.thunderbird.feature.mail.message.export

import net.thunderbird.core.outcome.Outcome

/**
* Result type for message export using the shared Outcome abstraction.
*
* Success carries Unit. Failure carries an [MessageExportError].
*/
typealias MessageExportResult = Outcome<Unit, MessageExportError>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package net.thunderbird.feature.mail.message.export

import com.eygraber.uri.Uri

/**
* API for exporting messages in a format-agnostic way, decoupled from platform specifics.
*
* The exporter operates on URIs so the UI/platform can select a source and destination.
* Implementations handle the I/O using platform-provided file system access.
*/
interface MessageExporter {
/**
* Export a message from the given source URI to the destination URI.
*
* @param sourceUri Uri of the source message
* @param destinationUri Uri of the destination for the exported message
* @return [MessageExportResult] indicating success or failure of the export operation
*/
suspend fun export(
sourceUri: Uri,
destinationUri: Uri,
): MessageExportResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package net.thunderbird.feature.mail.message.export

import kotlinx.datetime.LocalDateTime

/**
* Responsible for suggesting filesystem-friendly file names for exported messages.
*/
interface MessageFileNameSuggester {
/**
* Suggest a filename using a subject and (local) date & time, plus a required file extension.
*
* @param subject Subject to include in the file name. Callers should pass a reasonable default if the message has no subject.
* @param sentDateTime (local) date & time to include as a prefix in the file name.
* @param extension File extension without the leading dot, e.g., "eml".
*/
fun suggestFileName(subject: String, sentDateTime: LocalDateTime, extension: String): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package net.thunderbird.feature.mail.message.export

import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.datetime.LocalDateTime

class DefaultMessageFileNameSuggesterTest {

private val testSubject = DefaultMessageFileNameSuggester()

@Test
fun `suggestFileName should format date and time, normalize subject, and append extension`() {
// Arrange
val dt = LocalDateTime(2025, 10, 15, 14, 1)
val subject = "Meeting Reminder: Project Kickoff @ 10am!"
val extension = "eml"

// Act
val result = testSubject.suggestFileName(subject = subject, sentDateTime = dt, extension = extension)

// Assert
assertThat(result)
.isEqualTo("2025-10-15_14-01_meeting-reminder-project-kickoff-10am.eml")
}

@Test
fun `suggestFileName should normalize mixed case and non-alphanumeric characters`() {
// Arrange
val dt = LocalDateTime(2023, 1, 2, 3, 4)
val subject = " HeLLo___World!! -@- ###Thunderbird!!! "
val extension = "eml"

// Act
val result = testSubject.suggestFileName(subject = subject, sentDateTime = dt, extension = extension)

// Assert
assertThat(result).isEqualTo("2023-01-02_03-04_hello-world-thunderbird.eml")
}

@Test
fun `suggestFileName should fall back to message when subject is blank or symbols only`() {
// Arrange
val dt = LocalDateTime(1999, 12, 31, 23, 59)

// Act
val resultBlank = testSubject.suggestFileName(subject = "\t \n ", sentDateTime = dt, extension = "eml")
val resultSymbols = testSubject.suggestFileName(subject = "*** ### !!!", sentDateTime = dt, extension = "log")

// Assert
assertThat(resultBlank).isEqualTo("1999-12-31_23-59_message.eml")
assertThat(resultSymbols).isEqualTo("1999-12-31_23-59_message.log")
}
}
20 changes: 20 additions & 0 deletions feature/mail/message/export/impl-eml/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}

kotlin {
sourceSets {
commonMain.dependencies {
api(projects.feature.mail.message.export.api)
implementation(projects.core.outcome)
implementation(projects.core.file)

implementation(libs.kotlinx.io.core)
implementation(libs.uri)
}
}
}

android {
namespace = "net.thunderbird.feature.mail.message.export.eml"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package net.thunderbird.feature.mail.message.export.eml

import com.eygraber.uri.Uri
import net.thunderbird.core.file.FileManager
import net.thunderbird.core.file.FileOperationError
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.mail.message.export.MessageExportError
import net.thunderbird.feature.mail.message.export.MessageExportResult
import net.thunderbird.feature.mail.message.export.MessageExporter

class EmlMessageExporter(
private val fileManager: FileManager,
) : MessageExporter {
override suspend fun export(
sourceUri: Uri,
destinationUri: Uri,
): MessageExportResult {
val outcome = fileManager.copy(sourceUri = sourceUri, destinationUri = destinationUri)
return when (outcome) {
is Outcome.Success -> Outcome.Success(Unit)
is Outcome.Failure -> Outcome.Failure(
error = mapError(outcome.error),
cause = outcome.cause,
)
}
}

private fun mapError(
error: FileOperationError,
): MessageExportError {
return when (error) {
is FileOperationError.Unavailable -> MessageExportError.Unavailable(
error.uri,
error.message,
)
is FileOperationError.ReadFailed -> MessageExportError.Io(error.message)
is FileOperationError.WriteFailed -> MessageExportError.Io(error.message)
is FileOperationError.Unknown -> MessageExportError.Unknown(error.message)
}
}
}
Loading
Loading