diff --git a/mailserver/build.gradle.kts b/mailserver/build.gradle.kts index efe72b8..64cd535 100644 --- a/mailserver/build.gradle.kts +++ b/mailserver/build.gradle.kts @@ -1,7 +1,7 @@ plugins { - kotlin("jvm") version "1.8.0" apply false - kotlin("multiplatform") version "1.8.0" apply false - kotlin("plugin.serialization") version "1.8.0" apply false + kotlin("jvm") version "1.8.20" apply false + kotlin("multiplatform") version "1.8.20" apply false + kotlin("plugin.serialization") version "1.8.20" apply false id("kotlinx-atomicfu") apply false } diff --git a/mailserver/gradle/wrapper/gradle-wrapper.jar b/mailserver/gradle/wrapper/gradle-wrapper.jar index 249e583..41d9927 100644 Binary files a/mailserver/gradle/wrapper/gradle-wrapper.jar and b/mailserver/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mailserver/gradle/wrapper/gradle-wrapper.properties b/mailserver/gradle/wrapper/gradle-wrapper.properties index 60c76b3..db9a6b8 100644 --- a/mailserver/gradle/wrapper/gradle-wrapper.properties +++ b/mailserver/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists \ No newline at end of file +zipStorePath=wrapper/dists diff --git a/mailserver/imap-agent/src/commonMain/kotlin/ImapAgent.kt b/mailserver/imap-agent/src/commonMain/kotlin/ImapAgent.kt index ce5f19e..6bb1473 100644 --- a/mailserver/imap-agent/src/commonMain/kotlin/ImapAgent.kt +++ b/mailserver/imap-agent/src/commonMain/kotlin/ImapAgent.kt @@ -231,12 +231,14 @@ sealed interface State { fetch(context.command.tag, command) } is UidCommand -> { - val form = command.command - when (form) { + when (val form = command.command) { is FetchCommand -> fetch(context.command.tag, form) else -> error("shouldnt happen, it wouldnt get deserialized.") } } + is CheckCommand -> { + agent.transport.send(context.command.tag + OkResponse(text = "noop")) + } else -> authenticated.handle(context) // authenticated commands are allowed here } } diff --git a/mailserver/imap-agent/src/commonMain/kotlin/ImapFolder.kt b/mailserver/imap-agent/src/commonMain/kotlin/ImapFolder.kt index d23c889..2803147 100644 --- a/mailserver/imap-agent/src/commonMain/kotlin/ImapFolder.kt +++ b/mailserver/imap-agent/src/commonMain/kotlin/ImapFolder.kt @@ -21,15 +21,27 @@ interface ImapMailbox { suspend fun subscribe(folder: String) } +sealed class Flag(val value: String) { + object Replied: Flag("\\ANSWERED") + object Seen: Flag("\\SEEN") + object Trashed: Flag("\\DELETED") + object Draft: Flag("\\DRAFT") + object Flagged: Flag("\\FLAGGED") + object Recent: Flag("\\RECENT") + class Other(value: String): Flag(value) +} + interface ImapMessage { val uniqueIdentifier: Int val sequenceNumber: Int + val flags: Set val size: Long suspend fun typedMessage(): Message } +// TODO: maybe allow to lock? interface ImapFolder { val name: String @@ -50,6 +62,8 @@ interface ImapFolder { suspend fun messages(): List + suspend fun update(pos: Int, mode: Sequence.Mode, flags: Set) + suspend fun fetch(sequence: Sequence, dataItems: List): Map> { val messagesSnapshot = messages() @@ -102,20 +116,24 @@ interface ImapFolder { } return selectedMessages.associate { message -> - when (sequence.mode) { + val pos = when (sequence.mode) { Sequence.Mode.SequenceNumber -> message.sequenceNumber Sequence.Mode.Uid -> message.uniqueIdentifier - } to buildSet { + } + + pos to buildSet { // if sequence is UID the UID response is implicit if (sequence.mode == Sequence.Mode.Uid && DataItem.Fetch.Uid !in dataItems) { add(DataItem.Response.Uid(message.uniqueIdentifier.toString())) } for (item in dataItems) when (item) { - DataItem.Fetch.Flags -> add(DataItem.Response.Flags(listOf("\\Recent"))) + DataItem.Fetch.Flags -> add(DataItem.Response.Flags(message.flags.map { it.value })) DataItem.Fetch.Rfc822Size -> add(DataItem.Response.Rfc822Size(message.size)) DataItem.Fetch.Uid -> add(DataItem.Response.Uid(message.uniqueIdentifier.toString())) is DataItem.Fetch.BodyType -> { + if (item is DataItem.Fetch.Body) update(pos, sequence.mode, setOf(Flag.Seen)) + val typed = message.typedMessage() if (item.parts.isEmpty()) { @@ -137,9 +155,13 @@ interface ImapFolder { ) ) - is PartSpecifier.Fetch.Header -> { + PartSpecifier.Fetch.Header -> { add(DataItem.Response.Body(PartSpecifier.Response.Header(typed.headers.toList()))) } + + PartSpecifier.Fetch.Text -> { + add(DataItem.Response.Body(PartSpecifier.Response.Text(typed.body ?: ""))) + } } } } diff --git a/mailserver/imap/src/commonMain/kotlin/PartSpecifier.kt b/mailserver/imap/src/commonMain/kotlin/PartSpecifier.kt index 5b52f59..93096d8 100644 --- a/mailserver/imap/src/commonMain/kotlin/PartSpecifier.kt +++ b/mailserver/imap/src/commonMain/kotlin/PartSpecifier.kt @@ -12,7 +12,8 @@ sealed interface PartSpecifier { enum class Identifier(val raw: String, val fetchSerializer: Fetch.Serializer) { HeaderFields("HEADER.FIELDS", Fetch.HeaderFields), - Header("HEADER", Fetch.Header); + Header("HEADER", Fetch.Header), + Text("TEXT", Fetch.Text); companion object { fun from(raw: String): Identifier? { @@ -40,6 +41,14 @@ sealed interface PartSpecifier { } } + object Text: Fetch, Serializer { + override val identifier: Identifier = Identifier.Text + + override suspend fun deserialize(input: AsyncReader): Text { + return Text + } + } + // object Header: Fetch, Serializer
{ // override val identifier: Identifier = Identifier.Header // @@ -88,6 +97,17 @@ sealed interface PartSpecifier { } } + data class Text(val text: String): Response { + override val isInline: Boolean + get() = false + + override suspend fun serializeInline(output: AsyncWriter) { } + + override suspend fun serializeBody(output: AsyncWriter) { + output.writeStringUtf8(text) + } + } + data class Body(val message: Message): Response { override val isInline: Boolean = false diff --git a/mailserver/imap/src/commonMain/kotlin/Sequence.kt b/mailserver/imap/src/commonMain/kotlin/Sequence.kt index 237f71e..af85cf4 100644 --- a/mailserver/imap/src/commonMain/kotlin/Sequence.kt +++ b/mailserver/imap/src/commonMain/kotlin/Sequence.kt @@ -7,6 +7,7 @@ sealed class Sequence { abstract val mode: Mode data class Single(val pos: Position, override val mode: Mode): Sequence() + data class Set(val start: Position, val end: Position, override val mode: Mode): Sequence() enum class Mode { diff --git a/mailserver/imap/src/commonMain/kotlin/frames/command/CheckCommand.kt b/mailserver/imap/src/commonMain/kotlin/frames/command/CheckCommand.kt new file mode 100644 index 0000000..9d1ef0f --- /dev/null +++ b/mailserver/imap/src/commonMain/kotlin/frames/command/CheckCommand.kt @@ -0,0 +1,11 @@ +package dev.sitar.kmail.imap.frames.command + +import dev.sitar.kio.async.readers.AsyncReader + +object CheckCommand : ImapCommand, ImapCommandSerializer { + override val identifier = ImapCommand.Identifier.Check + + override suspend fun deserialize(input: AsyncReader): CheckCommand { + return CheckCommand + } +} \ No newline at end of file diff --git a/mailserver/imap/src/commonMain/kotlin/frames/command/ImapCommand.kt b/mailserver/imap/src/commonMain/kotlin/frames/command/ImapCommand.kt index fc5b55a..ab7588b 100644 --- a/mailserver/imap/src/commonMain/kotlin/frames/command/ImapCommand.kt +++ b/mailserver/imap/src/commonMain/kotlin/frames/command/ImapCommand.kt @@ -21,7 +21,8 @@ sealed interface ImapCommand { Create(CreateCommand), Status(StatusCommand), Notify(NotifyCommand), - Idle(IdleCommand); + Idle(IdleCommand), + Check(CheckCommand); companion object { fun findByIdentifier(identifier: String) : Identifier? { diff --git a/mailserver/imap/src/commonMain/kotlin/frames/command/UidCommand.kt b/mailserver/imap/src/commonMain/kotlin/frames/command/UidCommand.kt index 864071d..c1c5d0a 100644 --- a/mailserver/imap/src/commonMain/kotlin/frames/command/UidCommand.kt +++ b/mailserver/imap/src/commonMain/kotlin/frames/command/UidCommand.kt @@ -21,10 +21,12 @@ data class UidCommand(val command: ImapCommand): ImapCommand { } override suspend fun deserialize(input: AsyncReader): UidCommand { - val command = when (ImapCommand.Identifier.findByIdentifier(input.readCommandIdentifier())) { + val identifier = input.readCommandIdentifier() + + val command = when (ImapCommand.Identifier.findByIdentifier(identifier)) { ImapCommand.Identifier.Fetch -> FetchCommand.deserialize(Sequence.Mode.Uid, input) // ImapCommand.Identifier.Search - else -> TODO("bad syntax") + else -> TODO("got $identifier") } return UidCommand(command) diff --git a/mailserver/runner/src/main/kotlin/imap.kt b/mailserver/runner/src/main/kotlin/imap.kt index 248ba3b..9c66c58 100644 --- a/mailserver/runner/src/main/kotlin/imap.kt +++ b/mailserver/runner/src/main/kotlin/imap.kt @@ -1,8 +1,9 @@ package dev.sitar.kmail.runner +import dev.sitar.kmail.imap.Sequence import dev.sitar.kmail.imap.agent.* import dev.sitar.kmail.message.Message -import dev.sitar.kmail.runner.storage.* +import dev.sitar.kmail.runner.storage.StorageLayer import dev.sitar.kmail.runner.storage.formats.Mailbox import dev.sitar.kmail.runner.storage.formats.MailboxFolder import dev.sitar.kmail.runner.storage.formats.MailboxMessage @@ -12,6 +13,8 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.datetime.Clock import mu.KotlinLogging +import kotlin.math.max +import kotlin.math.min private val logger = KotlinLogging.logger { } @@ -26,7 +29,13 @@ suspend fun imap(socket: ServerSocketFactory, layer: ImapLayer): ImapServer = co server } -class KmailImapMessage(val folder: KmailImapFolder, override val sequenceNumber: Int, override val size: Long, val message: suspend () -> Message) : ImapMessage { +// TODO: this is horrible +class KmailImapMessage( + override val flags: Set, + override val sequenceNumber: Int, + override val size: Long, + private val message: suspend () -> Message, +) : ImapMessage { override val uniqueIdentifier: Int = sequenceNumber override suspend fun typedMessage(): Message { @@ -67,15 +76,29 @@ class KmailImapFolder(val folder: MailboxFolder) : ImapFolder { override suspend fun messages(): List { return folder.messages().mapIndexed { index, message -> KmailImapMessage( - this, + message.flags, index + 1, message.length, message::getMessage ) } } + // TODO: mix-matched nullability + suspend fun get(pos: Int, mode: Sequence.Mode): MailboxMessage { + return when (mode) { + Sequence.Mode.SequenceNumber -> folder.message(exists() - pos) + Sequence.Mode.Uid -> folder.messageByUid(pos)!! + } + } + + override suspend fun update(pos: Int, mode: Sequence.Mode, flags: Set) { + val message = get(pos, mode) + + message.updateFlags(flags) + } + override suspend fun onMessageStore(handler: (suspend (ImapMessage) -> Unit)?) { - folder.onMessageStore = { handler?.invoke(KmailImapMessage(this, exists(), it.size.toLong()) { it }) } + folder.onMessageStore = { handler?.invoke(KmailImapMessage(setOf(Flag.Recent), exists(), it.size.toLong(), { it })) } } } diff --git a/mailserver/runner/src/main/kotlin/launcher.kt b/mailserver/runner/src/main/kotlin/launcher.kt index 0f1be38..383bcee 100644 --- a/mailserver/runner/src/main/kotlin/launcher.kt +++ b/mailserver/runner/src/main/kotlin/launcher.kt @@ -16,6 +16,8 @@ suspend fun run(serverSocketFactory: ServerSocketFactory, connectionFactory: Con println(KMAIL_ASCII) logger.info("Kmail is starting.") + logger.debug { "Detected following configuration: $Config" } + val incoming = MutableSharedFlow() val outgoing = MutableSharedFlow() diff --git a/mailserver/runner/src/main/kotlin/launcherJvm.kt b/mailserver/runner/src/main/kotlin/launcherJvm.kt index ba16aac..f349e87 100644 --- a/mailserver/runner/src/main/kotlin/launcherJvm.kt +++ b/mailserver/runner/src/main/kotlin/launcherJvm.kt @@ -2,7 +2,6 @@ package dev.sitar.kmail.runner import dev.sitar.kmail.utils.connection.TlsCapableConnectionFactory import dev.sitar.kmail.utils.server.TlsCapableServerSocketFactory -import io.ktor.network.tls.* import kotlinx.coroutines.coroutineScope suspend fun main(): Unit = coroutineScope { diff --git a/mailserver/runner/src/main/kotlin/storage/filesystems/cached.kt b/mailserver/runner/src/main/kotlin/storage/filesystems/cached.kt index 529a5e4..d470980 100644 --- a/mailserver/runner/src/main/kotlin/storage/filesystems/cached.kt +++ b/mailserver/runner/src/main/kotlin/storage/filesystems/cached.kt @@ -53,6 +53,8 @@ class CachedFolder(val folder: FsFolder): FsFolder { private var hasRetrievedFiles = false var files = mutableListOf() private set + var fileContents = mutableMapOf() + private set override fun folder(name: String): FsFolder { return folders.find { it.name == name } ?: CachedFolder(folder.folder(name)).also { folders.add(it) } @@ -62,12 +64,16 @@ class CachedFolder(val folder: FsFolder): FsFolder { return CachedFolder(folder.createFolder(name)).also { folders.add(it) } } + override suspend fun getFile(name: String): FsFile? { + return files.find { it.name == name } ?: folder.getFile(name)?.also { files.add(it) } + } + override suspend fun listFiles(): List { if (hasRetrievedFiles) { return files } else { - files = folder.listFiles().map { CachedFile(it.name, it.size) { it.readContent() } }.toMutableList() + files = folder.listFiles().toMutableList() hasRetrievedFiles = true return files } @@ -83,14 +89,16 @@ class CachedFolder(val folder: FsFolder): FsFolder { } override suspend fun readFile(name: String): ByteArray? { - return files.find { it.name == name }?.readContent() ?: folder.readFile(name)?.also { files.add(CachedFile(name, it.size.toLong()) { it }) } + return folder.readFile(name)?.also { fileContents[name] = it } } override suspend fun writeFile(name: String, contents: ByteArray): FsFile { folder.writeFile(name, contents) - val cached = CachedFile(name, contents.size.toLong()) { contents } + val cached = FsFile(name, contents.size.toLong()) files.add(cached) + fileContents[name] = contents + return cached } @@ -103,10 +111,14 @@ class CachedFolder(val folder: FsFolder): FsFolder { files.remove(file) folder.files.add(file) } -} -class CachedFile(override val name: String, override val size: Long, val content: suspend () -> ByteArray) : FsFile { - override suspend fun readContent(): ByteArray { - return content() + override suspend fun rename(from: String, to: String) { + folder.rename(from, to) + + val file = files.find { it.name == from } ?: return + files.remove(file) + files.add(FsFile(to, file.size)) + + fileContents[to] = fileContents.remove(from) ?: return } } \ No newline at end of file diff --git a/mailserver/runner/src/main/kotlin/storage/filesystems/filesystem.kt b/mailserver/runner/src/main/kotlin/storage/filesystems/filesystem.kt index 0c36afe..fc5cec6 100644 --- a/mailserver/runner/src/main/kotlin/storage/filesystems/filesystem.kt +++ b/mailserver/runner/src/main/kotlin/storage/filesystems/filesystem.kt @@ -15,6 +15,8 @@ interface FsFolder: Attributable { suspend fun createFolder(name: String): FsFolder + suspend fun getFile(name: String): FsFile? + suspend fun listFiles(): List suspend fun listFolders(): List @@ -23,12 +25,10 @@ interface FsFolder: Attributable { suspend fun writeFile(name: String, contents: ByteArray): FsFile + // TODO: change this suspend fun move(file: String, folder: FsFolder) -} -interface FsFile { - val name: String - val size: Long + suspend fun rename(from: String, to: String) +} - suspend fun readContent(): ByteArray -} \ No newline at end of file +data class FsFile(val name: String, val size: Long) \ No newline at end of file diff --git a/mailserver/runner/src/main/kotlin/storage/filesystems/localJvm.kt b/mailserver/runner/src/main/kotlin/storage/filesystems/localJvm.kt index 5394f73..a993afd 100644 --- a/mailserver/runner/src/main/kotlin/storage/filesystems/localJvm.kt +++ b/mailserver/runner/src/main/kotlin/storage/filesystems/localJvm.kt @@ -45,8 +45,13 @@ class LocalFolder(val file: File): FsFolder, Attributable { return LocalFolder(file.resolve(name).also { it.mkdir() }) } + override suspend fun getFile(name: String): FsFile { + val file = file.resolve(name) + return FsFile(file.name, file.length()) + } + override suspend fun listFiles(): List { - return file.listFiles { file -> !file.name.startsWith("KMAIL_") }.orEmpty().map { LocalFile(it) } + return file.listFiles { file -> !file.name.startsWith("KMAIL_") }.orEmpty().map { FsFile(it.name, it.length()) } } override suspend fun listFolders(): List { @@ -60,7 +65,7 @@ class LocalFolder(val file: File): FsFolder, Attributable { override suspend fun writeFile(name: String, contents: ByteArray): FsFile { val file = file.resolve(name).also { it.createNewFile() } file.writeBytes(contents) - return LocalFile(file) + return FsFile(file.name, file.length()) } override suspend fun move(file: String, folder: FsFolder) { @@ -68,13 +73,21 @@ class LocalFolder(val file: File): FsFolder, Attributable { this.file.resolve(file).renameTo(folder.file.resolve(file)) } -} -class LocalFile(val file: File) : FsFile { - override val name: String = file.name - override val size: Long = file.length() - - override suspend fun readContent(): ByteArray { - return file.readBytes() + override suspend fun rename(from: String, to: String) { + file.resolve(from).renameTo(file.resolve(to)) } -} \ No newline at end of file +} + +//class LocalFile(val file: File) : FsFile { +// override val name: String = file.name +// override val size: Long = file.length() +// +// override suspend fun rename(name: String) { +// file.renameTo(file.resolveSibling(name)) +// } +// +// override suspend fun readContent(): ByteArray { +// return file.readBytes() +// } +//} \ No newline at end of file diff --git a/mailserver/runner/src/main/kotlin/storage/filesystems/memory.kt b/mailserver/runner/src/main/kotlin/storage/filesystems/memory.kt index 9d8efa5..90f1bb6 100644 --- a/mailserver/runner/src/main/kotlin/storage/filesystems/memory.kt +++ b/mailserver/runner/src/main/kotlin/storage/filesystems/memory.kt @@ -38,8 +38,12 @@ class InMemoryFolder(override val name: String): FsFolder { return InMemoryFolder(name).also { folders.add(it) } } + override suspend fun getFile(name: String): FsFile? { + return files.find { it.metadata.name == name }?.metadata + } + override suspend fun listFiles(): List { - return files + return files.map { it.metadata } } override suspend fun listFolders(): List { @@ -47,26 +51,28 @@ class InMemoryFolder(override val name: String): FsFolder { } override suspend fun readFile(name: String): ByteArray? { - return files.find { it.name == name }?.content + return files.find { it.metadata.name == name }?.content } override suspend fun writeFile(name: String, contents: ByteArray): FsFile { - val file = InMemoryFile(name, contents) + val file = InMemoryFile(FsFile(name, contents.size.toLong()), contents) files.add(file) - return file + return file.metadata } override suspend fun move(file: String, folder: FsFolder) { require(folder is InMemoryFolder) - val file = files.find { it.name == file } ?: return + val file = files.find { it.metadata.name == file } ?: return files.remove(file) folder.files.add(file) } -} -class InMemoryFile(override val name: String, val content: ByteArray): FsFile { - override val size: Long = content.size.toLong() + override suspend fun rename(from: String, to: String) { + val file = files.find { it.metadata.name == from } ?: return + files.remove(file) + files.add(InMemoryFile(FsFile(to, file.metadata.size), file.content)) + } +} - override suspend fun readContent(): ByteArray = content -} \ No newline at end of file +class InMemoryFile(val metadata: FsFile, val content: ByteArray) \ No newline at end of file diff --git a/mailserver/runner/src/main/kotlin/storage/filesystems/s3.kt b/mailserver/runner/src/main/kotlin/storage/filesystems/s3.kt index 708bcb1..8212845 100644 --- a/mailserver/runner/src/main/kotlin/storage/filesystems/s3.kt +++ b/mailserver/runner/src/main/kotlin/storage/filesystems/s3.kt @@ -110,6 +110,15 @@ class S3Folder(val fileSystem: S3FileSystem, val folderKey: Key): Attributable, return S3Folder(fileSystem, folderKey.append(name)) } + override suspend fun getFile(name: String): FsFile { + val obj = client.headObject { + this.bucket = config.bucket.name + this.key = "$folderKey$name" + } + + return FsFile(name, obj.contentLength) + } + override suspend fun listFolders(): List { // TODO: parse key return client.listObjectsV2 { @@ -124,7 +133,7 @@ class S3Folder(val fileSystem: S3FileSystem, val folderKey: Key): Attributable, bucket = config.bucket.name this.delimiter = "/" this.prefix = folderKey.toString() - }.contents.orEmpty().filterNot { it.key!!.split('/').last().startsWith("kmail_") }.map { S3File(this, it.key!!.split('/').last(), it.size) } + }.contents.orEmpty().filterNot { it.key!!.split('/').last().startsWith("kmail_") }.map { FsFile(it.key!!.split('/').last(), it.size) } } override suspend fun readFile(name: String): ByteArray? { @@ -147,7 +156,7 @@ class S3Folder(val fileSystem: S3FileSystem, val folderKey: Key): Attributable, body = ByteStream.fromBytes(contents) } - return S3File(this, "$folderKey$name", contents.size.toLong()) + return FsFile("$folderKey$name", contents.size.toLong()) } override suspend fun move(file: String, folder: FsFolder) { @@ -164,14 +173,40 @@ class S3Folder(val fileSystem: S3FileSystem, val folderKey: Key): Attributable, this.key = "$folderKey$file" } } -} -class S3File(val folder: S3Folder, override val name: String, override val size: Long): FsFile, S3Context by folder { - override suspend fun readContent(): ByteArray { - return folder.readFile(name)!! + override suspend fun rename(from: String, to: String) { + client.copyObject { + bucket = config.bucket.name + key = "$folderKey$to" + copySource = "/${config.bucket.name}/$folderKey$from" + } + + client.deleteObject { + bucket = config.bucket.name + this.key = "$folderKey$from" + } } } +//class S3File(val folder: S3Folder, override val name: String, override val size: Long): FsFile, S3Context by folder { +// override suspend fun rename(name: String) { +// client.copyObject { +// bucket = config.bucket.name +// key = "${folder.folderKey}$name" +// copySource = "/${config.bucket.name}/${folder.folderKey}${this@S3File.name}" +// } +// +// client.deleteObject { +// bucket = config.bucket.name +// this.key = "${folder.folderKey}${this@S3File.name}" +// } +// } +// +// override suspend fun readContent(): ByteArray { +// return folder.readFile(name)!! +// } +//} + private suspend fun S3Client.bucketExists(bucket: String) = try { headBucket { this.bucket = bucket } true diff --git a/mailserver/runner/src/main/kotlin/storage/formats/format.kt b/mailserver/runner/src/main/kotlin/storage/formats/format.kt index 13fb49d..de7a50c 100644 --- a/mailserver/runner/src/main/kotlin/storage/formats/format.kt +++ b/mailserver/runner/src/main/kotlin/storage/formats/format.kt @@ -1,5 +1,6 @@ package dev.sitar.kmail.runner.storage.formats +import dev.sitar.kmail.imap.agent.Flag import dev.sitar.kmail.message.Message import dev.sitar.kmail.runner.storage.Attributable @@ -27,12 +28,17 @@ interface MailboxFolder: Attributable { suspend fun messages(): List suspend fun message(index: Int): MailboxMessage + + suspend fun messageByUid(uid: Int): MailboxMessage? } interface MailboxMessage { val name: String val length: Long + val flags: Set + + suspend fun updateFlags(flags: Set) suspend fun getMessage(): Message } \ No newline at end of file diff --git a/mailserver/runner/src/main/kotlin/storage/formats/maildir.kt b/mailserver/runner/src/main/kotlin/storage/formats/maildir.kt index 917667b..27c7a50 100644 --- a/mailserver/runner/src/main/kotlin/storage/formats/maildir.kt +++ b/mailserver/runner/src/main/kotlin/storage/formats/maildir.kt @@ -1,16 +1,27 @@ package dev.sitar.kmail.runner.storage.formats +import dev.sitar.kmail.imap.agent.Flag import dev.sitar.kmail.message.Message import dev.sitar.kmail.runner.storage.Attributable import dev.sitar.kmail.runner.storage.filesystems.FsFile import dev.sitar.kmail.runner.storage.filesystems.FsFolder -import io.ktor.util.* import kotlinx.datetime.Clock +import mu.KotlinLogging import java.util.concurrent.atomic.AtomicInteger import kotlin.random.Random + +// TODO: this sucks @JvmInline value class MaildirUniqueName(val value: String) { + constructor(name: String, flags: Set? = null) : this(name + flags.orEmpty().joinToString(prefix = ":2,", separator = "") { + if (it !is Flag.Other) it::class.simpleName!!.first().toString() + else { + logger.error { "encountered a non-standard flag. not yet supported" } + "" + } + }) + constructor( timestamp: Long, hostname: String, @@ -21,23 +32,35 @@ value class MaildirUniqueName(val value: String) { deviceNumber: Int? = null, microseconds: Int? = null, processId: Int? = null, - deliveries: Int? = null - ): this("$timestamp.#${sequenceNumber}X${bootNumber}R${random}.$hostname") + deliveries: Int? = null, + flags: Set? = null + ) : this("$timestamp.S${sequenceNumber}X${bootNumber}R${random}.$hostname", flags) - val timestamp: Long get() = 0 - val unique: String get() = "" - val hostname: String get() = "" + val flags get() = value.split(':').last().run { + if (startsWith("2,")) removePrefix("2,").map { it.toFlag() } else emptyList() + } +} + +private fun Char.toFlag(): Flag { + return when (this) { + 'R' -> Flag.Replied + 'S' -> Flag.Seen + 'T' -> Flag.Trashed + 'D' -> Flag.Draft + 'F' -> Flag.Flagged + else -> error("unexpected maildir flag: $this") + } } class Maildir(val user: FsFolder) : Mailbox, Attributable by user { - override val inbox: MaildirInbox = MaildirInbox(user) + override val inbox: MaildirInbox = MaildirInbox(this, user) override suspend fun folders(): List { return user.listFolders().map { it.name }.minus(arrayOf("new", "cur", "tmp")).plus("INBOX") } override fun folder(name: String): MailboxFolder { - return if (name == "INBOX") inbox else MaildirFolder(user.folder(name)) + return if (name == "INBOX") inbox else MaildirFolder(this, user.folder(name)) } override suspend fun createFolder(name: String) { @@ -49,10 +72,10 @@ class Maildir(val user: FsFolder) : Mailbox, Attributable by user { } } -class MaildirInbox(val user: FsFolder) : MailboxFolder, Attributable by user { - val tmp: MaildirFolder = MaildirFolder(user.folder("tmp")) - val new: MaildirFolder = MaildirFolder(user.folder("new")) - val cur: MaildirFolder = MaildirFolder(user.folder("cur")) +class MaildirInbox(val mailbox: Maildir, val user: FsFolder) : MailboxFolder, Attributable by user { + val tmp: MaildirFolder = MaildirFolder(mailbox, user.folder("tmp")) + val new: MaildirFolder = MaildirFolder(mailbox, user.folder("new")) + val cur: MaildirFolder = MaildirFolder(mailbox, user.folder("cur")) override val name: String get() = "INBOX" @@ -71,6 +94,10 @@ class MaildirInbox(val user: FsFolder) : MailboxFolder, Attributable by user { return messages()[index] } + override suspend fun messageByUid(uid: Int): MailboxMessage? { + return cur.messageByUid(uid) + } + suspend fun store(message: Message) { val name = tmp.store(message) tmp.moveFile(name, new) @@ -78,7 +105,7 @@ class MaildirInbox(val user: FsFolder) : MailboxFolder, Attributable by user { } } -class MaildirFolder(val folder: FsFolder) : MailboxFolder, Attributable by folder { +class MaildirFolder(val mailbox: Maildir, val folder: FsFolder) : MailboxFolder, Attributable by folder { override val name: String = folder.name override var onMessageStore: (suspend (Message) -> Unit)? = null @@ -87,14 +114,18 @@ class MaildirFolder(val folder: FsFolder) : MailboxFolder, Attributable by folde override suspend fun newMessages(): Int = if (folder.name == "new") totalMessages() else 0 - override suspend fun messages(): List { - return folder.listFiles().map { MaildirMessage(it) } + override suspend fun messages(): List { + return folder.listFiles().map { MaildirMessage(it.name, it.size, folder, mailbox) } } - override suspend fun message(index: Int): MailboxMessage { + override suspend fun message(index: Int): MaildirMessage { return messages()[index] } + override suspend fun messageByUid(uid: Int): MailboxMessage? { + return messages().find { it.name.contains(uid.toString()) } + } + suspend fun store(message: Message): MaildirUniqueName { // TODO: populate this val name = MaildirUniqueName( @@ -111,7 +142,6 @@ class MaildirFolder(val folder: FsFolder) : MailboxFolder, Attributable by folde ) folder.writeFile(name.value, message.asText().encodeToByteArray()) - onMessageStore?.invoke(message) return name } @@ -124,12 +154,27 @@ class MaildirFolder(val folder: FsFolder) : MailboxFolder, Attributable by folde // TODO: replace this with atomicfu private val deliveries: AtomicInteger = AtomicInteger(0) -class MaildirMessage(val file: FsFile): MailboxMessage { - override val name: String = file.name +private val logger = KotlinLogging.logger { } - override val length: Long = file.size +class MaildirMessage(var fileName: String, override val length: Long, var folder: FsFolder, val mailbox: Maildir): MailboxMessage { + override val name: String = fileName.split(':').first() + + override val flags: Set = MaildirUniqueName(fileName).flags.toSet() + + override suspend fun updateFlags(flags: Set) { + if (Flag.Seen in flags) { + if (folder.name == "new") { + mailbox.inbox.new.moveFile(MaildirUniqueName(fileName), mailbox.inbox.cur) + folder = mailbox.inbox.cur.folder + } + } + + val newName = MaildirUniqueName(name, flags).value + folder.rename(fileName, newName) + fileName = newName + } override suspend fun getMessage(): Message { - return Message.fromText(file.readContent().decodeToString()) + return Message.fromText(folder.readFile(fileName)!!.decodeToString()) } } \ No newline at end of file