diff --git a/.gitignore b/.gitignore index 8fe66b93a4a..467acf470d9 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ captures/ # Screenshots adb-screenshots/ +# Extracted eml files +eml-files/ diff --git a/app-common/build.gradle.kts b/app-common/build.gradle.kts index 05e33eb9fd7..373e8e396a0 100644 --- a/app-common/build.gradle.kts +++ b/app-common/build.gradle.kts @@ -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) diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/FeatureMailModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/FeatureMailModule.kt index 5ccb4b8b5b9..c6ee5c3a1f9 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/FeatureMailModule.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/FeatureMailModule.kt @@ -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 @@ -13,6 +14,8 @@ import org.koin.dsl.module internal val appCommonFeatureMailModule = module { + includes(mailMessageModule) + single> { BaseAccountBackendStorageFactory( legacyFactory = get(), diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/message/MailMessageModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/message/MailMessageModule.kt new file mode 100644 index 00000000000..d25b1eb3d33 --- /dev/null +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/message/MailMessageModule.kt @@ -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 { DefaultMessageFileNameSuggester() } + + single { + EmlMessageExporter( + fileManager = get(), + ) + } +} diff --git a/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt b/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt index c9c81aae4ed..82d31bec105 100644 --- a/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt +++ b/app-k9mail/src/debug/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt @@ -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 @@ -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), ) } } diff --git a/app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt b/app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt index f380c538fa8..105500b3919 100644 --- a/app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt +++ b/app-k9mail/src/release/kotlin/app/k9mail/featureflag/K9FeatureFlagFactory.kt @@ -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 @@ -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), ) } } diff --git a/app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt b/app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt index 6fce270d82e..64f87c8d4c0 100644 --- a/app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt +++ b/app-thunderbird/src/beta/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt @@ -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 @@ -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), ) } } diff --git a/app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt b/app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt index 51cc37abb64..55a67b708cd 100644 --- a/app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt +++ b/app-thunderbird/src/daily/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt @@ -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 @@ -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), ) } } diff --git a/app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt b/app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt index 174a8ee9751..d4e3ba7b3b1 100644 --- a/app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt +++ b/app-thunderbird/src/debug/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt @@ -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 @@ -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), ) } } diff --git a/app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt b/app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt index 86d68154e66..75b216e3c30 100644 --- a/app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt +++ b/app-thunderbird/src/release/kotlin/net/thunderbird/android/featureflag/TbFeatureFlagFactory.kt @@ -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 @@ -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), ) } } diff --git a/feature/mail/message/export/api/build.gradle.kts b/feature/mail/message/export/api/build.gradle.kts new file mode 100644 index 00000000000..ab7df830bb9 --- /dev/null +++ b/feature/mail/message/export/api/build.gradle.kts @@ -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" +} diff --git a/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/DefaultMessageFileNameSuggester.kt b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/DefaultMessageFileNameSuggester.kt new file mode 100644 index 00000000000..b038dc7ed08 --- /dev/null +++ b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/DefaultMessageFileNameSuggester.kt @@ -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("-+") + } +} diff --git a/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExportError.kt b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExportError.kt new file mode 100644 index 00000000000..d5df4abfc23 --- /dev/null +++ b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExportError.kt @@ -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 +} diff --git a/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExportResult.kt b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExportResult.kt new file mode 100644 index 00000000000..397689470b5 --- /dev/null +++ b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExportResult.kt @@ -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 diff --git a/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExporter.kt b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExporter.kt new file mode 100644 index 00000000000..108f7ac2b24 --- /dev/null +++ b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageExporter.kt @@ -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 +} diff --git a/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageFileNameSuggester.kt b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageFileNameSuggester.kt new file mode 100644 index 00000000000..49c3e70d27b --- /dev/null +++ b/feature/mail/message/export/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/MessageFileNameSuggester.kt @@ -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 +} diff --git a/feature/mail/message/export/api/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/DefaultMessageFileNameSuggesterTest.kt b/feature/mail/message/export/api/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/DefaultMessageFileNameSuggesterTest.kt new file mode 100644 index 00000000000..92be1d7089b --- /dev/null +++ b/feature/mail/message/export/api/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/DefaultMessageFileNameSuggesterTest.kt @@ -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") + } +} diff --git a/feature/mail/message/export/impl-eml/build.gradle.kts b/feature/mail/message/export/impl-eml/build.gradle.kts new file mode 100644 index 00000000000..3dd320d42ea --- /dev/null +++ b/feature/mail/message/export/impl-eml/build.gradle.kts @@ -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" +} diff --git a/feature/mail/message/export/impl-eml/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/eml/EmlMessageExporter.kt b/feature/mail/message/export/impl-eml/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/eml/EmlMessageExporter.kt new file mode 100644 index 00000000000..97d04732ff7 --- /dev/null +++ b/feature/mail/message/export/impl-eml/src/commonMain/kotlin/net/thunderbird/feature/mail/message/export/eml/EmlMessageExporter.kt @@ -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) + } + } +} diff --git a/feature/mail/message/export/impl-eml/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/eml/EmlMessageExporterTest.kt b/feature/mail/message/export/impl-eml/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/eml/EmlMessageExporterTest.kt new file mode 100644 index 00000000000..8fe997b17de --- /dev/null +++ b/feature/mail/message/export/impl-eml/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/eml/EmlMessageExporterTest.kt @@ -0,0 +1,106 @@ +package net.thunderbird.feature.mail.message.export.eml + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.eygraber.uri.toKmpUri +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.file.FileOperationError +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.feature.mail.message.export.MessageExportError + +class EmlMessageExporterTest { + + private val fakeFileManager = FakeFileManager() + private val testSubject = EmlMessageExporter(fileManager = fakeFileManager) + + @Test + fun `export should delegate to FileManager and return success`() = runTest { + // Arrange + val source = "mem://source.eml".toKmpUri() + val dest = "mem://dest.eml".toKmpUri() + fakeFileManager.nextResult = Outcome.Success(Unit) + + // Act + val result = testSubject.export(sourceUri = source, destinationUri = dest) + + // Assert + assertThat(result.isSuccess).isEqualTo(true) + assertThat(fakeFileManager.lastSource).isEqualTo(source) + assertThat(fakeFileManager.lastDestination).isEqualTo(dest) + } + + @Test + fun `export should map Unavailable error`() = runTest { + // Arrange + val source = "mem://source.eml".toKmpUri() + val dest = "mem://dest.eml".toKmpUri() + fakeFileManager.nextResult = Outcome.Failure( + FileOperationError.Unavailable(uri = source, message = "cannot open"), + ) + + // Act + val result = testSubject.export(sourceUri = source, destinationUri = dest) + + // Assert + assertThat(result.isFailure).isEqualTo(true) + val failure = result as Outcome.Failure + assertThat(failure.error).isInstanceOf(MessageExportError.Unavailable::class) + assertThat((failure.error as MessageExportError.Unavailable).uri).isEqualTo(source) + } + + @Test + fun `export should map ReadFailed to Io`() = runTest { + // Arrange + val source = "mem://source.eml".toKmpUri() + val dest = "mem://dest.eml".toKmpUri() + fakeFileManager.nextResult = Outcome.Failure( + FileOperationError.ReadFailed(uri = source, message = "read err"), + ) + + // Act + val result = testSubject.export(sourceUri = source, destinationUri = dest) + + // Assert + assertThat(result.isFailure).isEqualTo(true) + val failure = result as Outcome.Failure + assertThat(failure.error).isInstanceOf(MessageExportError.Io::class) + } + + @Test + fun `export should map WriteFailed to Io`() = runTest { + // Arrange + val source = "mem://source.eml".toKmpUri() + val dest = "mem://dest.eml".toKmpUri() + fakeFileManager.nextResult = Outcome.Failure( + FileOperationError.WriteFailed(uri = dest, message = "write err"), + ) + + // Act + val result = testSubject.export(sourceUri = source, destinationUri = dest) + + // Assert + assertThat(result.isFailure).isEqualTo(true) + val failure = result as Outcome.Failure + assertThat(failure.error).isInstanceOf(MessageExportError.Io::class) + } + + @Test + fun `export should map Unknown error`() = runTest { + // Arrange + val source = "mem://source.eml".toKmpUri() + val dest = "mem://dest.eml".toKmpUri() + fakeFileManager.nextResult = Outcome.Failure( + FileOperationError.Unknown(message = "mystery"), + ) + + // Act + val result = testSubject.export(sourceUri = source, destinationUri = dest) + + // Assert + assertThat(result.isFailure).isEqualTo(true) + val failure = result as Outcome.Failure + assertThat(failure.error).isInstanceOf(MessageExportError.Unknown::class) + } +} diff --git a/feature/mail/message/export/impl-eml/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/eml/FakeFileManager.kt b/feature/mail/message/export/impl-eml/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/eml/FakeFileManager.kt new file mode 100644 index 00000000000..b0938da8b53 --- /dev/null +++ b/feature/mail/message/export/impl-eml/src/commonTest/kotlin/net/thunderbird/feature/mail/message/export/eml/FakeFileManager.kt @@ -0,0 +1,19 @@ +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 + +internal class FakeFileManager : FileManager { + var lastSource: Uri? = null + var lastDestination: Uri? = null + + var nextResult: Outcome = Outcome.Success(Unit) + + override suspend fun copy(sourceUri: Uri, destinationUri: Uri): Outcome { + lastSource = sourceUri + lastDestination = destinationUri + return nextResult + } +} diff --git a/legacy/ui/legacy/build.gradle.kts b/legacy/ui/legacy/build.gradle.kts index 67df239ee32..e0c4ec7d75e 100644 --- a/legacy/ui/legacy/build.gradle.kts +++ b/legacy/ui/legacy/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(projects.feature.telemetry.api) implementation(projects.feature.mail.message.list) implementation(projects.feature.mail.message.composer) + implementation(projects.feature.mail.message.export.api) compileOnly(projects.mail.protocols.imap) @@ -70,6 +71,7 @@ dependencies { implementation(libs.mime4j.core) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) implementation(libs.uri) implementation(libs.glide) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFeatureFlags.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFeatureFlags.kt new file mode 100644 index 00000000000..c52650f64fb --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFeatureFlags.kt @@ -0,0 +1,8 @@ +package com.fsck.k9.ui.messageview + +import net.thunderbird.core.featureflag.FeatureFlagKey + +object MessageViewFeatureFlags { + + val ActionExportEml = FeatureFlagKey("message_view_action_export_eml") +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt index 816bc0e446a..51b1095fd40 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt @@ -26,9 +26,11 @@ import androidx.core.view.WindowInsetsCompat.Type.navigationBars import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.lifecycleScope import app.k9mail.core.android.common.activity.CreateDocumentResultContract import app.k9mail.core.ui.legacy.designsystem.atom.icon.Icons import app.k9mail.legacy.message.controller.MessageReference +import com.eygraber.uri.toKmpUri import com.fsck.k9.K9 import com.fsck.k9.activity.MessageCompose import com.fsck.k9.activity.MessageLoaderHelper @@ -45,6 +47,7 @@ import com.fsck.k9.mail.Flag import com.fsck.k9.mailstore.AttachmentViewInfo import com.fsck.k9.mailstore.LocalMessage import com.fsck.k9.mailstore.MessageViewInfo +import com.fsck.k9.provider.RawMessageProvider import com.fsck.k9.ui.R import com.fsck.k9.ui.base.extensions.withArguments import com.fsck.k9.ui.choosefolder.ChooseFolderActivity @@ -55,13 +58,20 @@ import com.fsck.k9.ui.messageview.MessageCryptoPresenter.MessageCryptoMvpView import com.fsck.k9.ui.settings.account.AccountSettingsActivity import com.fsck.k9.ui.share.ShareIntentBuilder import java.util.Locale +import kotlin.time.Instant +import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import net.thunderbird.core.android.account.LegacyAccountDto import net.thunderbird.core.android.account.LegacyAccountDtoManager +import net.thunderbird.core.featureflag.FeatureFlagProvider import net.thunderbird.core.logging.legacy.Log import net.thunderbird.core.preference.GeneralSettingsManager import net.thunderbird.core.ui.theme.api.Theme import net.thunderbird.core.ui.theme.manager.ThemeManager import net.thunderbird.feature.mail.folder.api.OutboxFolderManager +import net.thunderbird.feature.mail.message.export.MessageExporter +import net.thunderbird.feature.mail.message.export.MessageFileNameSuggester import org.koin.android.ext.android.inject import org.openintents.openpgp.util.OpenPgpIntentStarter @@ -78,6 +88,7 @@ class MessageViewFragment : private val shareIntentBuilder: ShareIntentBuilder by inject() private val generalSettingsManager: GeneralSettingsManager by inject() private val outboxFolderManager: OutboxFolderManager by inject() + private val featureFlagProvider: FeatureFlagProvider by inject() private val createDocumentLauncher: ActivityResultLauncher = registerForActivityResult(CreateDocumentResultContract()) { documentUri -> @@ -100,6 +111,10 @@ class MessageViewFragment : private var showProgressThreshold: Long? = null private var preferredUnsubscribeUri: UnsubscribeUri? = null + private val messageExporter: MessageExporter by inject() + + private val fileNameSuggester: MessageFileNameSuggester by inject() + /** * Used to temporarily store the destination folder for refile operations if a confirmation * dialog is shown. @@ -115,6 +130,9 @@ class MessageViewFragment : private var isDeleteMenuItemDisabled: Boolean = false private var wasMessageMarkedAsOpened: Boolean = false + // Tracks whether the current Create Document flow is for exporting EML (and not for attachments) + private var pendingEmlExport: Boolean = false + private var isActive: Boolean = false private set @@ -318,6 +336,8 @@ class MessageViewFragment : menu.findItem(R.id.move_to_drafts).isVisible = isOutbox menu.findItem(R.id.unsubscribe).isVisible = canMessageBeUnsubscribed() menu.findItem(R.id.show_headers).isVisible = true + menu.findItem(R.id.export_eml).isVisible = + featureFlagProvider.provide(MessageViewFeatureFlags.ActionExportEml).isEnabled() menu.findItem(R.id.compose).isVisible = true val toggleTheme = menu.findItem(R.id.toggle_message_view_theme) @@ -354,6 +374,13 @@ class MessageViewFragment : R.id.move_to_drafts -> onMoveToDrafts() R.id.unsubscribe -> onUnsubscribe() R.id.show_headers -> onShowHeaders() + R.id.export_eml -> if ( + featureFlagProvider.provide(MessageViewFeatureFlags.ActionExportEml).isEnabled() + ) { + onExportEml() + } else { + return true + } else -> return false } @@ -631,6 +658,24 @@ class MessageViewFragment : if (uri == null) return require(uri.scheme == ContentResolver.SCHEME_CONTENT) { "content: URI required" } + if (pendingEmlExport) { + // Handle EML export via exporter and reset flag regardless of outcome + val exportUri = uri + pendingEmlExport = false + viewLifecycleOwner.lifecycleScope.launch { + val ctx = requireContext() + val rawUri = RawMessageProvider.getRawMessageUri(messageReference) + val result = messageExporter.export( + sourceUri = rawUri.toKmpUri(), + destinationUri = exportUri.toKmpUri(), + ) + if (result.isFailure) { + Toast.makeText(ctx, R.string.message_view_status_attachment_not_saved, Toast.LENGTH_LONG).show() + } + } + return + } + createAttachmentController(currentAttachmentViewInfo).saveAttachmentTo(uri) } @@ -662,6 +707,33 @@ class MessageViewFragment : copyMessage(messageReference, destinationFolderId) } + @OptIn(kotlin.time.ExperimentalTime::class) + private fun onExportEml() { + // Mark this flow as an EML export so the result handler doesn't touch attachment logic + pendingEmlExport = true + val subject = message?.subject ?: "" + val dateMillis = (message?.sentDate ?: message?.internalDate)?.time + val localDateTime = if (dateMillis != null) { + Instant.fromEpochMilliseconds(dateMillis) + .toLocalDateTime(TimeZone.UTC) + } else { + // Fallback to current local time if message has no dates + Instant.fromEpochMilliseconds(System.currentTimeMillis()) + .toLocalDateTime(TimeZone.currentSystemDefault()) + } + val suggestedName = fileNameSuggester.suggestFileName(subject, localDateTime, "eml") + try { + createDocumentLauncher.launch( + input = CreateDocumentResultContract.Input( + title = suggestedName, + mimeType = "message/rfc822", + ), + ) + } catch (e: ActivityNotFoundException) { + Toast.makeText(requireContext(), R.string.error_activity_not_found, Toast.LENGTH_LONG).show() + } + } + private fun onSendAlternate() { val message = checkNotNull(message) diff --git a/legacy/ui/legacy/src/main/res/menu/message_list_option_menu.xml b/legacy/ui/legacy/src/main/res/menu/message_list_option_menu.xml index 780a80ae0f0..11e2e9c277d 100644 --- a/legacy/ui/legacy/src/main/res/menu/message_list_option_menu.xml +++ b/legacy/ui/legacy/src/main/res/menu/message_list_option_menu.xml @@ -136,6 +136,14 @@ app:showAsAction="never" /> + + + Copy Unsubscribe Show headers + Export as EML Address copied to clipboard Addresses copied to clipboard diff --git a/scripts/extract-eml.sh b/scripts/extract-eml.sh new file mode 100755 index 00000000000..72e0f412d49 --- /dev/null +++ b/scripts/extract-eml.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Extract exported .eml files from a connected Android device using adb. +# +# Default behavior: pull all .eml files from the Downloads folder into a local directory ./eml-files, preserving +# filenames. If a filename already exists locally, it will be overwritten. After a successful pull, the file is removed +# from the device by default. +# +# To keep files on the device, add --keep|-k to the command line. +# +# Requirements: +# - adb must be installed and a device connected with USB debugging enabled +# - The .eml files should have been exported on-device via "Export as EML" into Downloads. +# +# Usage: +# scripts/extract-eml.sh [--keep|-k] +# Pulls .eml files into ./eml-files (fixed target directory). +# If --keep|-k is specified, files are not deleted from the device after pulling. + +TARGET_DIR="./eml-files" +SOURCE_DIRS=( + "/sdcard/Download" + "/storage/emulated/0/Download" +) + +# Options +KEEP=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -k|--keep) + KEEP=1 + shift + ;; + -h|--help) + echo "Usage: scripts/extract-eml.sh [--keep|-k]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: scripts/extract-eml.sh [--keep|-k]" >&2 + exit 2 + ;; + esac +done + +mkdir -p "${TARGET_DIR}" + +echo "Waiting for device..." +adb wait-for-device + +if ! adb get-state >/dev/null 2>&1; then + echo "Error: No device detected by adb. Make sure USB debugging is enabled and run 'adb devices'." >&2 + exit 1 +fi + +EML_LIST=$(adb shell ls /sdcard/Download/*.eml 2>/dev/null || true) + +if [[ -z "${EML_LIST}" ]]; then + echo "No .eml files found on device in: ${SOURCE_DIRS[*]}" + exit 0 +fi + +COUNT=0 +DELETED=0 + +while IFS= read -r REMOTE_PATH; do + [[ -z "${REMOTE_PATH}" ]] && continue + BASENAME=$(basename "${REMOTE_PATH}") + DEST_PATH="${TARGET_DIR}/${BASENAME}" + + echo "Pulling (overwriting if exists): ${REMOTE_PATH} -> ${DEST_PATH}" + if ! adb pull "${REMOTE_PATH}" "${DEST_PATH}" >/dev/null; then + echo "Warning: Failed to pull ${REMOTE_PATH}" >&2 + continue + fi + ((COUNT++)) || true + + if [[ "${KEEP}" != "1" ]]; then + if adb shell rm -f "${REMOTE_PATH}" >/dev/null 2>&1; then + ((DELETED++)) || true + echo "Deleted on device: ${REMOTE_PATH}" + else + echo "Warning: Failed to delete on device: ${REMOTE_PATH}" >&2 + fi + fi + +done < <(printf '%s\n' "${EML_LIST}") + +if [[ ${COUNT} -gt 0 ]]; then + if [[ "${KEEP}" != "1" ]]; then + echo "Done. Pulled ${COUNT} file(s) into ${TARGET_DIR} and deleted ${DELETED} on device." + else + echo "Done. Pulled ${COUNT} file(s) into ${TARGET_DIR}. (Kept files on device)" + fi +else + echo "No files pulled." +fi + diff --git a/settings.gradle.kts b/settings.gradle.kts index 460271ebe9b..3d1f4ca648a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -99,6 +99,8 @@ include( ":feature:mail:folder:api", ":feature:mail:message:composer", ":feature:mail:message:list", + ":feature:mail:message:export:api", + ":feature:mail:message:export:impl-eml", ) include(