Skip to content

Commit cb4fcda

Browse files
committed
feat(message-export): add eml exporter
1 parent 0b0c833 commit cb4fcda

File tree

10 files changed

+250
-0
lines changed

10 files changed

+250
-0
lines changed

app-common/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ dependencies {
4545
implementation(projects.feature.widget.messageList)
4646

4747
implementation(projects.feature.mail.message.export.api)
48+
implementation(projects.feature.mail.message.export.implEml)
4849

4950
implementation(projects.mail.protocols.imap)
5051
implementation(projects.backend.imap)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
package net.thunderbird.app.common.feature.mail.message
22

33
import net.thunderbird.feature.mail.message.export.DefaultMessageFileNameSuggester
4+
import net.thunderbird.feature.mail.message.export.MessageExporter
45
import net.thunderbird.feature.mail.message.export.MessageFileNameSuggester
6+
import net.thunderbird.feature.mail.message.export.eml.EmlMessageExporter
57
import org.koin.dsl.module
68

79
internal val mailMessageModule = module {
810
single<MessageFileNameSuggester> { DefaultMessageFileNameSuggester() }
11+
12+
single<MessageExporter> {
13+
EmlMessageExporter(
14+
fileManager = get(),
15+
)
16+
}
917
}

feature/mail/message/export/api/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ plugins {
55
kotlin {
66
sourceSets {
77
commonMain.dependencies {
8+
implementation(libs.uri)
9+
implementation(projects.core.outcome)
10+
}
811
}
912
}
1013

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package net.thunderbird.feature.mail.message.export
2+
3+
import com.eygraber.uri.Uri
4+
5+
/**
6+
* Error type for message export failures.
7+
*
8+
* Keep this minimal and format-agnostic. Additional cases can be added later if needed.
9+
*/
10+
sealed interface MessageExportError {
11+
/** Source or destination couldn't be opened or accessed. */
12+
data class Unavailable(val uri: Uri, val message: String? = null) : MessageExportError
13+
14+
/** Generic I/O error while copying/exporting. */
15+
data class Io(val message: String? = null) : MessageExportError
16+
17+
/** Fallback when the error type can't be determined. */
18+
data class Unknown(val message: String? = null) : MessageExportError
19+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package net.thunderbird.feature.mail.message.export
2+
3+
import net.thunderbird.core.outcome.Outcome
4+
5+
/**
6+
* Result type for message export using the shared Outcome abstraction.
7+
*
8+
* Success carries Unit. Failure carries an [MessageExportError].
9+
*/
10+
typealias MessageExportResult = Outcome<Unit, MessageExportError>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package net.thunderbird.feature.mail.message.export
2+
3+
import com.eygraber.uri.Uri
4+
5+
/**
6+
* API for exporting messages in a format-agnostic way, decoupled from platform specifics.
7+
*
8+
* The exporter operates on URIs so the UI/platform can select a source and destination.
9+
* Implementations handle the I/O using platform-provided file system access.
10+
*/
11+
interface MessageExporter {
12+
/**
13+
* Export a message from the given source URI to the destination URI.
14+
*
15+
* @param sourceUri Uri of the source message
16+
* @param destinationUri Uri of the destination for the exported message
17+
* @return [MessageExportResult] indicating success or failure of the export operation
18+
*/
19+
suspend fun export(
20+
sourceUri: Uri,
21+
destinationUri: Uri,
22+
): MessageExportResult
23+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
plugins {
2+
id(ThunderbirdPlugins.Library.kmp)
3+
}
4+
5+
kotlin {
6+
sourceSets {
7+
commonMain.dependencies {
8+
api(projects.feature.mail.message.export.api)
9+
implementation(projects.core.outcome)
10+
implementation(projects.core.file)
11+
12+
implementation(libs.kotlinx.io.core)
13+
implementation(libs.uri)
14+
}
15+
}
16+
}
17+
18+
android {
19+
namespace = "net.thunderbird.feature.mail.message.export.eml"
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package net.thunderbird.feature.mail.message.export.eml
2+
3+
import com.eygraber.uri.Uri
4+
import net.thunderbird.core.file.FileManager
5+
import net.thunderbird.core.file.FileOperationError
6+
import net.thunderbird.core.outcome.Outcome
7+
import net.thunderbird.feature.mail.message.export.MessageExportError
8+
import net.thunderbird.feature.mail.message.export.MessageExportResult
9+
import net.thunderbird.feature.mail.message.export.MessageExporter
10+
11+
class EmlMessageExporter(
12+
private val fileManager: FileManager,
13+
) : MessageExporter {
14+
override suspend fun export(
15+
sourceUri: Uri,
16+
destinationUri: Uri,
17+
): MessageExportResult {
18+
val outcome = fileManager.copy(sourceUri = sourceUri, destinationUri = destinationUri)
19+
return when (outcome) {
20+
is Outcome.Success -> Outcome.Success(Unit)
21+
is Outcome.Failure -> Outcome.Failure(
22+
error = mapError(outcome.error),
23+
cause = outcome.cause,
24+
)
25+
}
26+
}
27+
28+
private fun mapError(
29+
error: FileOperationError,
30+
): MessageExportError {
31+
return when (error) {
32+
is FileOperationError.Unavailable -> MessageExportError.Unavailable(
33+
error.uri,
34+
error.message,
35+
)
36+
is FileOperationError.ReadFailed -> MessageExportError.Io(error.message)
37+
is FileOperationError.WriteFailed -> MessageExportError.Io(error.message)
38+
is FileOperationError.Unknown -> MessageExportError.Unknown(error.message)
39+
}
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package net.thunderbird.feature.mail.message.export.eml
2+
3+
import assertk.assertThat
4+
import assertk.assertions.isEqualTo
5+
import assertk.assertions.isInstanceOf
6+
import com.eygraber.uri.toKmpUri
7+
import kotlin.test.Test
8+
import kotlinx.coroutines.test.runTest
9+
import net.thunderbird.core.file.FileOperationError
10+
import net.thunderbird.core.outcome.Outcome
11+
import net.thunderbird.feature.mail.message.export.MessageExportError
12+
13+
class EmlMessageExporterTest {
14+
15+
private val fakeFileManager = FakeFileManager()
16+
private val testSubject = EmlMessageExporter(fileManager = fakeFileManager)
17+
18+
@Test
19+
fun `export should delegate to FileManager and return success`() = runTest {
20+
// Arrange
21+
val source = "mem://source.eml".toKmpUri()
22+
val dest = "mem://dest.eml".toKmpUri()
23+
fakeFileManager.nextResult = Outcome.Success(Unit)
24+
25+
// Act
26+
val result = testSubject.export(sourceUri = source, destinationUri = dest)
27+
28+
// Assert
29+
assertThat(result.isSuccess).isEqualTo(true)
30+
assertThat(fakeFileManager.lastSource).isEqualTo(source)
31+
assertThat(fakeFileManager.lastDestination).isEqualTo(dest)
32+
}
33+
34+
@Test
35+
fun `export should map Unavailable error`() = runTest {
36+
// Arrange
37+
val source = "mem://source.eml".toKmpUri()
38+
val dest = "mem://dest.eml".toKmpUri()
39+
fakeFileManager.nextResult = Outcome.Failure(
40+
FileOperationError.Unavailable(uri = source, message = "cannot open"),
41+
)
42+
43+
// Act
44+
val result = testSubject.export(sourceUri = source, destinationUri = dest)
45+
46+
// Assert
47+
assertThat(result.isFailure).isEqualTo(true)
48+
val failure = result as Outcome.Failure
49+
assertThat(failure.error).isInstanceOf(MessageExportError.Unavailable::class)
50+
assertThat((failure.error as MessageExportError.Unavailable).uri).isEqualTo(source)
51+
}
52+
53+
@Test
54+
fun `export should map ReadFailed to Io`() = runTest {
55+
// Arrange
56+
val source = "mem://source.eml".toKmpUri()
57+
val dest = "mem://dest.eml".toKmpUri()
58+
fakeFileManager.nextResult = Outcome.Failure(
59+
FileOperationError.ReadFailed(uri = source, message = "read err"),
60+
)
61+
62+
// Act
63+
val result = testSubject.export(sourceUri = source, destinationUri = dest)
64+
65+
// Assert
66+
assertThat(result.isFailure).isEqualTo(true)
67+
val failure = result as Outcome.Failure
68+
assertThat(failure.error).isInstanceOf(MessageExportError.Io::class)
69+
}
70+
71+
@Test
72+
fun `export should map WriteFailed to Io`() = runTest {
73+
// Arrange
74+
val source = "mem://source.eml".toKmpUri()
75+
val dest = "mem://dest.eml".toKmpUri()
76+
fakeFileManager.nextResult = Outcome.Failure(
77+
FileOperationError.WriteFailed(uri = dest, message = "write err"),
78+
)
79+
80+
// Act
81+
val result = testSubject.export(sourceUri = source, destinationUri = dest)
82+
83+
// Assert
84+
assertThat(result.isFailure).isEqualTo(true)
85+
val failure = result as Outcome.Failure
86+
assertThat(failure.error).isInstanceOf(MessageExportError.Io::class)
87+
}
88+
89+
@Test
90+
fun `export should map Unknown error`() = runTest {
91+
// Arrange
92+
val source = "mem://source.eml".toKmpUri()
93+
val dest = "mem://dest.eml".toKmpUri()
94+
fakeFileManager.nextResult = Outcome.Failure(
95+
FileOperationError.Unknown(message = "mystery"),
96+
)
97+
98+
// Act
99+
val result = testSubject.export(sourceUri = source, destinationUri = dest)
100+
101+
// Assert
102+
assertThat(result.isFailure).isEqualTo(true)
103+
val failure = result as Outcome.Failure
104+
assertThat(failure.error).isInstanceOf(MessageExportError.Unknown::class)
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package net.thunderbird.feature.mail.message.export.eml
2+
3+
import com.eygraber.uri.Uri
4+
import net.thunderbird.core.file.FileManager
5+
import net.thunderbird.core.file.FileOperationError
6+
import net.thunderbird.core.outcome.Outcome
7+
8+
internal class FakeFileManager : FileManager {
9+
var lastSource: Uri? = null
10+
var lastDestination: Uri? = null
11+
12+
var nextResult: Outcome<Unit, FileOperationError> = Outcome.Success(Unit)
13+
14+
override suspend fun copy(sourceUri: Uri, destinationUri: Uri): Outcome<Unit, FileOperationError> {
15+
lastSource = sourceUri
16+
lastDestination = destinationUri
17+
return nextResult
18+
}
19+
}

0 commit comments

Comments
 (0)