diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index ab95c154..96aad397 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -55,6 +55,12 @@
# with the server's order (pinned pages lost, 1.67.27/28). Field names stay
# stable across releases like Familie's.
-keep class com.gatecontrol.android.layout.** { ; }
+# Mail (plugin gatecontrol-mail): answers are read from the Gson tree
+# (MailJson) and the offline copy keeps the server's raw JSON (MailCache),
+# nothing reflective; the models keep their field names like the door's, in
+# case they are ever (de)serialized by reflection. PluginAvailability.mail is
+# covered by the familie rule above.
+-keep class com.gatecontrol.android.mail.** { ; }
# ── WireGuard ─────────────────────────────────────────────
-keep class com.wireguard.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 08238547..c3b36fc8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -120,6 +120,28 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/gatecontrol/android/GateControlApp.kt b/app/src/main/java/com/gatecontrol/android/GateControlApp.kt
index 53ad3258..b77b3f2e 100644
--- a/app/src/main/java/com/gatecontrol/android/GateControlApp.kt
+++ b/app/src/main/java/com/gatecontrol/android/GateControlApp.kt
@@ -113,6 +113,13 @@ class GateControlApp : Application() {
Timber.e(e, "Failed to start GateDrop")
}
+ // Mail: the Microsoft redirect only with the plugin; its offline copy goes with the plugin or the login.
+ try {
+ EntryPointAccessors.fromApplication(this, com.gatecontrol.android.mail.MailEntryPoint::class.java).mailStartup().start()
+ } catch (e: Throwable) {
+ Timber.w(e, "Mail: not started")
+ }
+
// Google Play build: in-app update (network watch, install in the background).
if (com.gatecontrol.android.update.PlayUpdater.enabled) {
try {
diff --git a/app/src/main/java/com/gatecontrol/android/MailSigninActivity.kt b/app/src/main/java/com/gatecontrol/android/MailSigninActivity.kt
new file mode 100644
index 00000000..3a2304d4
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/MailSigninActivity.kt
@@ -0,0 +1,32 @@
+package com.gatecontrol.android
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import com.gatecontrol.android.mail.MailMicrosoft
+import com.gatecontrol.android.mail.MailSigninRedirects
+
+/**
+ * Receives `msauth://com.gatecontrol.client/?code=…`, the browser's answer to "Mit Microsoft anmelden" of a
+ * mail account, and hands it to the running Mail screen ([MailSigninRedirects]), bringing up [MainActivity] again
+ * (which closes the Custom Tab above it). Invisible, never shown in Recents.
+ *
+ * Exported because the browser opens it, and enabled only while the server offers Mail (MailStartup). The link
+ * carries a one-time code that is useless without the PKCE verifier, and the Mail screen drops any answer whose
+ * state it did not create.
+ */
+class MailSigninActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val raw = intent?.dataString
+ if (raw != null && MailMicrosoft.isRedirect(raw)) {
+ MailSigninRedirects.offer(raw)
+ startActivity(
+ Intent(this, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ )
+ }
+ finish()
+ }
+}
diff --git a/app/src/main/java/com/gatecontrol/android/familie/PluginDirectory.kt b/app/src/main/java/com/gatecontrol/android/familie/PluginDirectory.kt
index 54ab03eb..45defda5 100644
--- a/app/src/main/java/com/gatecontrol/android/familie/PluginDirectory.kt
+++ b/app/src/main/java/com/gatecontrol/android/familie/PluginDirectory.kt
@@ -69,6 +69,11 @@ data class PluginAvailability(
* listed for this user: the tab "Drive".
*/
val drive: Boolean = false,
+ /**
+ * The Mail plugin is listed with an app tab for this user (installed and the person allowed, its `appVisible`):
+ * the tab "Mail" (contract D).
+ */
+ val mail: Boolean = false,
) {
companion object {
val NONE = PluginAvailability()
@@ -88,6 +93,7 @@ data class PluginAvailability(
gatedrop = plugins.any { it.id == com.gatecontrol.android.gatedrop.GATEDROP_PLUGIN },
vault = plugins.any { it.id == com.gatecontrol.android.vault.VAULT_PLUGIN },
drive = plugins.any { it.id == com.gatecontrol.android.drive.DRIVE_PLUGIN },
+ mail = plugins.any { it.id == com.gatecontrol.android.mail.MAIL_PLUGIN && it.tab != null },
)
}
diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropPush.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropPush.kt
index 435706be..0bd55d50 100644
--- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropPush.kt
+++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropPush.kt
@@ -129,13 +129,14 @@ class GateDropPushHandler @Inject constructor(
}
}
-/** Realtime events of the push stream for the chats, GateDrop and Drive. */
+/** Realtime events of the push stream for the chats, GateDrop, Drive and Mail. */
@Singleton
class AppPluginEventSink @Inject constructor(
private val chats: com.gatecontrol.android.chat.ChatEventSink,
private val gatedrop: dagger.Lazy,
private val gatedropRepository: dagger.Lazy,
private val drive: dagger.Lazy,
+ private val mail: dagger.Lazy,
) : PluginEventSink {
override fun onPluginEvent(plugin: String, type: String, data: JsonElement?) {
if (plugin == GATEDROP_PLUGIN) {
@@ -146,6 +147,10 @@ class AppPluginEventSink @Inject constructor(
runCatching { drive.get().onPluginEvent(type, data) }
return
}
+ if (plugin == com.gatecontrol.android.mail.MAIL_PLUGIN) {
+ runCatching { mail.get().onPluginEvent(type, data) }
+ return
+ }
chats.onPluginEvent(plugin, type, data)
}
@@ -155,6 +160,8 @@ class AppPluginEventSink @Inject constructor(
runCatching { gatedropRepository.get().pollPendingSoon() }
// Drive's changes of that time: whatever is open loads again.
runCatching { drive.get().onStreamOpen() }
+ // Mail: the lists load again when the tab is open
+ runCatching { mail.get().onStreamOpen() }
}
}
diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt
index 540beec3..7b21de5a 100644
--- a/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt
+++ b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt
@@ -2,7 +2,7 @@ package com.gatecontrol.android.layout
/**
* Pages beside the start tab (pure, unit-tested): functions that are a tab
- * otherwise (Chats, Vault, Drive, Netzwerk, Familie) pinned to Start, and
+ * otherwise (Chats, Mail, Vault, Drive, Netzwerk, Familie) pinned to Start, and
* widget pages ("Widgets 2", "Widgets 3" …) holding quick actions and
* widgets like Start does.
*
@@ -22,13 +22,14 @@ object StartPages {
const val MARKER = "page:"
const val CHATS = "chats"
+ const val MAIL = "mail"
const val VAULT = "vault"
const val DRIVE = "drive"
const val NETWORK = "network"
const val FAMILIE = "familie"
/** Functions that can be pages, in the order of the tab bar. */
- val FEATURES = listOf(CHATS, VAULT, DRIVE, NETWORK, FAMILIE)
+ val FEATURES = listOf(CHATS, MAIL, VAULT, DRIVE, NETWORK, FAMILIE)
private val WIDGETS = Regex("^w([0-9]{1,3})$")
diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt
new file mode 100644
index 00000000..445dc6f7
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt
@@ -0,0 +1,326 @@
+package com.gatecontrol.android.mail
+
+import com.gatecontrol.android.data.SettingsRepository
+import com.gatecontrol.android.data.SetupRepository
+import com.gatecontrol.android.network.ApiClient
+import com.gatecontrol.android.network.ApiClientProvider
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeoutOrNull
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.ResponseBody
+import retrofit2.Response
+import timber.log.Timber
+import java.io.File
+import java.io.IOException
+import java.net.URLEncoder
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The Mail plugin's app API (contract C2) through the core's plugin bridge
+ * (`/api/v1/client/plugins/gatecontrol-mail/api/…`) and GateControl's media routes. The only place that knows the
+ * routes: one method per route, answers parsed by [MailJson]. Paths are built from fixed segments and server ids only.
+ * Never throws (except cancellation).
+ */
+@Singleton
+class MailApi @Inject constructor(
+ private val setupRepository: SetupRepository,
+ private val apiClientProvider: ApiClientProvider,
+ private val settingsRepository: SettingsRepository,
+) {
+ companion object {
+ const val CODE_INVALID = "invalid_answer"
+ const val CODE_OFFLINE = "offline"
+ /** The plugin API under the server address (the WebView's pictures and `GET providers` are read raw). */
+ const val API_PREFIX = "api/v1/client/plugins/$MAIL_PLUGIN/api/"
+ private const val TIMEOUT_MS = 25_000L
+ /** Adding an account checks IMAP and SMTP first; the server's own time-outs are 30 s each. */
+ private const val ACCOUNT_TIMEOUT_MS = 75_000L
+ private const val UPLOAD_TIMEOUT_MS = 30 * 60_000L
+ private const val IMAGE_TIMEOUT_MS = 20_000L
+ private const val IMAGE_MAX_BYTES = 6L * 1024 * 1024
+ }
+
+ private val _unauthorized = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
+
+ /** A call answered 401 (the device's token is gone): the offline copy is no longer this person's to keep. */
+ val unauthorized: SharedFlow = _unauthorized.asSharedFlow()
+
+ suspend fun lang(): String =
+ runCatching { settingsRepository.getLocale().first() }.getOrNull()?.takeIf { it == "de" || it == "en" } ?: "de"
+
+ private suspend fun langQuery(): Map = mapOf("lang" to lang())
+
+ private suspend fun call(
+ timeoutMs: Long = TIMEOUT_MS,
+ media: Boolean = false,
+ request: suspend ApiClient.() -> Response,
+ parse: (JsonObject) -> T?,
+ ): MResult {
+ val url = setupRepository.getServerUrl()
+ if (url.isBlank() || !setupRepository.isConfigured()) return MResult.NotConfigured
+ return try {
+ val client = if (media) apiClientProvider.mediaClient(url) else apiClientProvider.getClient(url)
+ val response = withTimeoutOrNull(timeoutMs) { client.request() } ?: return MResult.Offline
+ if (response.isSuccessful) {
+ val body = response.body() ?: JsonObject()
+ if (MailJson.isRefusal(body)) return MResult.Failed(MailJson.failureCode(response.code(), body), response.code(), body.obj())
+ parse(body)?.let { MResult.Ok(it) } ?: MResult.Failed(CODE_INVALID, response.code())
+ } else {
+ if (response.code() == 401) _unauthorized.tryEmit(401)
+ val err = errorJson(response)
+ MResult.Failed(MailJson.failureCode(response.code(), err), response.code(), err?.obj())
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: IOException) {
+ MResult.Offline
+ } catch (e: Exception) {
+ Timber.d("Mail: call failed (%s)", e.javaClass.simpleName)
+ MResult.Failed(CODE_INVALID, 0)
+ }
+ }
+
+ /** The error object (`{ error: { code, … } }`), or the whole answer when it has none. */
+ private fun JsonObject.obj(): JsonObject = get("error")?.takeIf { it.isJsonObject }?.asJsonObject ?: this
+
+ private fun errorJson(response: Response<*>): JsonObject? = runCatching {
+ response.errorBody()?.use { b -> b.string().takeIf { it.length < 65_536 } }
+ ?.let { JsonParser.parseString(it).takeIf { e -> e.isJsonObject }?.asJsonObject }
+ }.getOrNull()
+
+ private suspend fun get(path: String, query: Map = emptyMap(), parse: (JsonObject) -> T?) =
+ call(request = { pluginGet(MAIL_PLUGIN, path, query + langQuery()) }, parse = parse)
+
+ private suspend fun post(path: String, body: JsonObject, timeoutMs: Long = TIMEOUT_MS, parse: (JsonObject) -> T?) =
+ call(timeoutMs = timeoutMs, request = { pluginPost(MAIL_PLUGIN, path, body, langQuery()) }, parse = parse)
+
+ private suspend fun put(path: String, body: JsonObject, parse: (JsonObject) -> T?) =
+ call(request = { pluginPut(MAIL_PLUGIN, path, body, langQuery()) }, parse = parse)
+
+ private suspend fun patch(path: String, body: JsonObject, timeoutMs: Long = TIMEOUT_MS, parse: (JsonObject) -> T?) =
+ call(timeoutMs = timeoutMs, request = { pluginPatch(MAIL_PLUGIN, path, body, langQuery()) }, parse = parse)
+
+ private suspend fun del(path: String, parse: (JsonObject) -> T?) =
+ call(request = { pluginDelete(MAIL_PLUGIN, path, langQuery()) }, parse = parse)
+
+ private val unit: (JsonObject) -> Unit = { }
+
+ /** A GET whose answer is not an object (`GET providers` is an array): read raw, parsed here. */
+ private suspend fun getAny(path: String, parse: (JsonElement) -> T?): MResult {
+ val url = setupRepository.getServerUrl()
+ if (url.isBlank() || !setupRepository.isConfigured()) return MResult.NotConfigured
+ return try {
+ val r = withTimeoutOrNull(TIMEOUT_MS) {
+ apiClientProvider.getClient(url).getRaw(API_PREFIX + path + "?lang=" + URLEncoder.encode(lang(), "UTF-8"))
+ } ?: return MResult.Offline
+ val text = r.body()?.use { b -> b.string().takeIf { it.length < 1_000_000 } }
+ if (!r.isSuccessful) {
+ if (r.code() == 401) _unauthorized.tryEmit(401)
+ val err = r.errorBody()?.use { b -> runCatching { JsonParser.parseString(b.string()).asJsonObject }.getOrNull() }
+ return MResult.Failed(MailJson.failureCode(r.code(), err), r.code(), err)
+ }
+ val el = text?.let { runCatching { JsonParser.parseString(it) }.getOrNull() } ?: return MResult.Failed(CODE_INVALID, r.code())
+ parse(el)?.let { MResult.Ok(it) } ?: MResult.Failed(CODE_INVALID, r.code())
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: IOException) {
+ MResult.Offline
+ } catch (e: Exception) {
+ Timber.d("Mail: call failed (%s)", e.javaClass.simpleName)
+ MResult.Failed(CODE_INVALID, 0)
+ }
+ }
+
+ // ─── account, status ────────────────────────────────────────────
+
+ /** `GET me` with the raw answer (kept for offline). */
+ suspend fun me(): MResult> = get("me") { o -> MailJson.me(o)?.let { it to o } }
+
+ suspend fun putSettings(threads: Boolean? = null, swipeLeft: String? = null, swipeRight: String? = null): MResult =
+ put("settings", MailJson.settingsBody(threads, swipeLeft, swipeRight), MailJson::settingsAnswer)
+
+ suspend fun providers(): MResult> = getAny("providers", MailJson::providers)
+
+ suspend fun discover(email: String): MResult = post("accounts/discover", MailJson.discoverBody(email), parse = MailJson::discover)
+
+ suspend fun addAccount(body: JsonObject): MResult = post("accounts", body, ACCOUNT_TIMEOUT_MS, MailJson::accountAnswer)
+
+ suspend fun addMicrosoft(body: JsonObject): MResult = post("accounts/microsoft", body, ACCOUNT_TIMEOUT_MS, MailJson::accountAnswer)
+
+ suspend fun reloginMicrosoft(id: Long, body: JsonObject): MResult = post("accounts/$id/microsoft", body, ACCOUNT_TIMEOUT_MS, MailJson::accountAnswer)
+
+ suspend fun patchAccount(id: Long, body: JsonObject): MResult = patch("accounts/$id", body, ACCOUNT_TIMEOUT_MS, MailJson::accountAnswer)
+
+ suspend fun deleteAccount(id: Long): MResult = del("accounts/$id", unit)
+
+ // ─── folders, lists, search ─────────────────────────────────────
+
+ suspend fun folders(): MResult>, JsonObject>> = get("folders") { o -> MailJson.folders(o)?.let { it to o } }
+
+ suspend fun messages(query: Map): MResult> = get("messages", query) { o -> MailJson.page(o)?.let { it to o } }
+
+ suspend fun search(query: Map): MResult = get("search", query, MailJson::searchPage)
+
+ suspend fun thread(threadId: Long): MResult> = get("threads/$threadId", parse = MailJson::thread)
+
+ suspend fun message(id: Long): MResult> = get("messages/$id") { o -> MailJson.message(o)?.let { it to o } }
+
+ suspend fun allowImages(id: Long, sender: Boolean): MResult> =
+ post("messages/$id/images", MailJson.imagesBody(sender)) { o -> MailJson.message(o)?.let { it to o } }
+
+ suspend fun flags(ids: Collection, unread: Boolean? = null, flagged: Boolean? = null): MResult> =
+ post("messages/flags", MailJson.flagsBody(ids, unread, flagged), parse = MailJson::changedItems)
+
+ suspend fun move(ids: Collection, folderId: Long): MResult> =
+ post("messages/move", MailJson.moveBody(ids, folderId), parse = MailJson::changedItems)
+
+ suspend fun archive(ids: Collection): MResult> = post("messages/archive", MailJson.idsBody(ids), parse = MailJson::changedItems)
+
+ suspend fun delete(ids: Collection): MResult> = post("messages/delete", MailJson.idsBody(ids), parse = MailJson::changedItems)
+
+ suspend fun junk(ids: Collection): MResult> = post("messages/junk", MailJson.idsBody(ids), parse = MailJson::changedItems)
+
+ suspend fun setTab(ids: Collection, tab: String): MResult> = post("messages/tab", MailJson.tabBody(ids, tab), parse = MailJson::changedItems)
+
+ suspend fun attachmentMedia(messageId: Long, attachmentId: Long): MResult =
+ get("messages/$messageId/attachments/$attachmentId", parse = MailJson::attachmentMedia)
+
+ /** A media file of the plugin as a stream (the caller closes it). */
+ suspend fun media(mediaId: String): MResult {
+ if (!Regex("^[A-Za-z0-9_.~-]{1,200}$").matches(mediaId)) return MResult.Failed(CODE_INVALID, 0)
+ val url = setupRepository.getServerUrl()
+ if (url.isBlank() || !setupRepository.isConfigured()) return MResult.NotConfigured
+ return try {
+ val r = withTimeoutOrNull(TIMEOUT_MS) { apiClientProvider.mediaClient(url).pluginMediaGet(MAIL_PLUGIN, mediaId) } ?: return MResult.Offline
+ val body = r.body()
+ if (r.isSuccessful && body != null) MResult.Ok(body) else {
+ r.errorBody()?.close()
+ MResult.Failed("http_${r.code()}", r.code())
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: IOException) {
+ MResult.Offline
+ } catch (e: Exception) {
+ MResult.Failed(CODE_INVALID, 0)
+ }
+ }
+
+ /**
+ * A picture the mail's HTML asks for ([MailLogic.interceptPath]), with the app's own sign-in; null when it cannot
+ * be had. `img/` answers `{ mime, size, data }` (Base64, at most 700 KB); `cid//` answers
+ * `{ mediaId, mime, size }` and the picture is the plugin's media file. Only raster pictures (never SVG), a few MB.
+ */
+ suspend fun picture(path: String): Pair? {
+ val url = setupRepository.getServerUrl()
+ if (url.isBlank() || !setupRepository.isConfigured()) return null
+ return try {
+ withTimeoutOrNull(IMAGE_TIMEOUT_MS) {
+ val r = apiClientProvider.getClient(url).pluginGet(MAIL_PLUGIN, path)
+ val o = r.body()?.takeIf { r.isSuccessful && !MailJson.isRefusal(it) } ?: run {
+ r.errorBody()?.close()
+ return@withTimeoutOrNull null
+ }
+ val picture = MailJson.picture(o) ?: return@withTimeoutOrNull null
+ val bytes = picture.data ?: picture.mediaId?.let { media ->
+ val m = apiClientProvider.mediaClient(url).pluginMediaGet(MAIL_PLUGIN, media)
+ val body = m.body()
+ if (!m.isSuccessful || body == null) {
+ m.errorBody()?.close()
+ body?.close()
+ null
+ } else {
+ body.use { b -> b.byteStream().use { input -> readAtMost(input, IMAGE_MAX_BYTES) } }
+ }
+ } ?: return@withTimeoutOrNull null
+ bytes to picture.mime
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /** All of [input], null when it is longer than [max]. */
+ private fun readAtMost(input: java.io.InputStream, max: Long): ByteArray? {
+ val out = java.io.ByteArrayOutputStream()
+ val buf = ByteArray(16 * 1024)
+ while (true) {
+ val n = input.read(buf)
+ if (n < 0) break
+ out.write(buf, 0, n)
+ if (out.size() > max) return null
+ }
+ return out.toByteArray()
+ }
+
+ /** A device file → its media id (`PUT …/media`, raw body). */
+ suspend fun upload(file: File, mime: String): MResult {
+ val type = mime.toMediaTypeOrNull() ?: "application/octet-stream".toMediaTypeOrNull()!!
+ return call(UPLOAD_TIMEOUT_MS, media = true, request = { pluginMediaUpload(MAIL_PLUGIN, file.asRequestBody(type)) }) { o ->
+ o.get("mediaId")?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { Regex("^[A-Za-z0-9_.~-]{1,200}$").matches(it) }
+ }
+ }
+
+ // ─── writing ────────────────────────────────────────────────────
+
+ suspend fun createDraft(body: JsonObject): MResult = post("drafts", body, parse = MailJson::draftAnswer)
+
+ suspend fun updateDraft(id: Long, body: JsonObject): MResult = put("drafts/$id", body, MailJson::draftAnswer)
+
+ suspend fun draft(id: Long): MResult = get("drafts/$id", parse = MailJson::draftAnswer)
+
+ suspend fun deleteDraft(id: Long): MResult = del("drafts/$id", unit)
+
+ suspend fun send(draftId: Long, sendAt: Long?): MResult = post("drafts/$draftId/send", MailJson.sendBody(sendAt), parse = MailJson::send)
+
+ /** Stops a send still waiting: the draft it came from (null when the server names none). */
+ suspend fun cancel(outboxId: Long): MResult =
+ when (val r = post("outbox/$outboxId/cancel", JsonObject()) { o -> listOfNotNull(MailJson.cancelled(o)) }) {
+ is MResult.Ok -> MResult.Ok(r.value.firstOrNull())
+ else -> r.notOk()
+ }
+
+ suspend fun outbox(): MResult> = get("outbox", parse = MailJson::outbox)
+
+ suspend fun contacts(q: String): MResult> = get("contacts", mapOf("q" to q.take(100)), MailJson::contacts)
+
+ // ─── AI ─────────────────────────────────────────────────────────
+
+ suspend fun ai(messageId: Long): MResult = get("ai/$messageId", parse = MailJson::ai)
+
+ /** "Kurz gesagt" etc.: the result that is there, or (with [again]) asked anew (at most 60 a person and hour). */
+ suspend fun runAi(messageId: Long, again: Boolean = false): MResult =
+ post("ai/$messageId", JsonObject().apply { if (again) addProperty("again", true) }, timeoutMs = 60_000L, parse = MailJson::ai)
+
+ suspend fun todoToFamily(messageId: Long, index: Int): MResult =
+ post("ai/$messageId/todo/${index.coerceAtLeast(0)}", MailJson.todoBody(), parse = MailJson::where)
+
+ // ─── Drive ──────────────────────────────────────────────────────
+
+ suspend fun suggest(messageId: Long, attachmentId: Long): MResult =
+ post("messages/$messageId/attachments/$attachmentId/drive/suggest", JsonObject(), timeoutMs = 45_000L, parse = MailJson::suggest)
+
+ suspend fun saveToDrive(messageId: Long, attachmentId: Long, folderId: Long?, name: String?, remember: Boolean?): MResult =
+ post("messages/$messageId/attachments/$attachmentId/drive/save", MailJson.driveSaveBody(folderId, name, remember), timeoutMs = 60_000L, parse = MailJson::savedAnswer)
+
+ suspend fun saveAll(messageId: Long): MResult = post("messages/$messageId/drive/save-all", JsonObject(), timeoutMs = 90_000L, parse = MailJson::saveAll)
+
+ suspend fun filing(): MResult> = get("filing", parse = MailJson::filing)
+
+ suspend fun confirmFiling(id: Long, folderId: Long?, name: String?): MResult =
+ post("filing/$id/confirm", MailJson.driveSaveBody(folderId, name, null), timeoutMs = 60_000L, parse = MailJson::savedAnswer)
+
+ suspend fun dismissFiling(id: Long): MResult = post("filing/$id/dismiss", JsonObject(), parse = unit)
+}
diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt
new file mode 100644
index 00000000..e4ccc7bb
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt
@@ -0,0 +1,196 @@
+package com.gatecontrol.android.mail
+
+import android.content.Context
+import com.gatecontrol.android.data.SetupRepository
+import com.gatecontrol.android.familie.PluginDirectory
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.security.MessageDigest
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The Mail tab's offline copy (contract D "Offline"): the last lists (the inbox at least 30 days back), the mails
+ * opened, `me` and the folders — the server's answers as they came (read with [MailJson], never by reflection). Kept
+ * per login (server + token) in the app's private, not backed-up files; another login, the plugin gone from the
+ * server or the token revoked delete it ([wipe]), and so do the files opened from mails.
+ */
+@Singleton
+class MailCache @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val setupRepository: SetupRepository,
+) {
+ private val mutex = Mutex()
+
+ private fun root(): File = File(context.noBackupFilesDir, DIR)
+
+ private fun ownerDir(): File? {
+ val key = PluginDirectory.ownerKey(setupRepository.getServerUrl(), setupRepository.getApiToken()).takeIf { it.isNotEmpty() } ?: return null
+ return File(root(), key)
+ }
+
+ private fun name(key: String): String {
+ val d = MessageDigest.getInstance("SHA-256").digest(key.toByteArray())
+ return d.take(12).joinToString("") { "%02x".format(it) }
+ }
+
+ private suspend fun io(block: (File) -> T?): T? = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ val dir = ownerDir() ?: return@withLock null
+ // only this login's copy stays
+ root().listFiles()?.filter { it.name != dir.name }?.forEach { runCatching { it.deleteRecursively() } }
+ runCatching { block(dir) }.getOrNull()
+ }
+ }
+
+ private fun read(file: File): JsonObject? = file.takeIf { it.isFile }?.let { MailJson.parse(it.readText()) }
+
+ private fun write(file: File, o: JsonObject) {
+ file.parentFile?.mkdirs()
+ val tmp = File(file.parentFile, file.name + ".tmp")
+ tmp.writeText(o.toString())
+ if (!tmp.renameTo(file)) {
+ file.delete()
+ tmp.renameTo(file)
+ }
+ }
+
+ // ─── me, folders ────────────────────────────────────────────────
+
+ suspend fun putMe(raw: JsonObject) { io { write(File(it, "me.json"), raw) } }
+
+ suspend fun me(): MMe? = io { dir -> read(File(dir, "me.json"))?.let(MailJson::me) }
+
+ suspend fun putFolders(raw: JsonObject) { io { write(File(it, "folders.json"), raw) } }
+
+ suspend fun folders(): Map>? = io { dir -> read(File(dir, "folders.json"))?.let(MailJson::folders) }
+
+ // ─── lists ──────────────────────────────────────────────────────
+
+ /**
+ * A list's page as it came. The first page ([first]) starts the copy afresh, later ones are added; what lies
+ * beyond the kept window goes ([MailCacheLogic.prune]).
+ */
+ suspend fun putList(key: String, raw: JsonObject, first: Boolean, now: Long = System.currentTimeMillis()) {
+ io { dir ->
+ val file = File(dir, "lists/${name(key)}.json")
+ val items = raw.getAsJsonArray("items")?.toList()?.filter { it.isJsonObject }?.map { it.asJsonObject }.orEmpty()
+ val old = if (first) emptyList() else read(file)?.getAsJsonArray("items")?.mapNotNull { it.takeIf { e -> e.isJsonObject }?.asJsonObject }.orEmpty()
+ val kept = MailCacheLogic.prune(MailCacheLogic.mergeRaw(old, items), now)
+ val out = JsonObject().apply {
+ add("items", JsonArray().apply { kept.forEach { add(it) } })
+ raw.get("counts")?.takeIf { it.isJsonObject }?.let { add("counts", it) }
+ ?: read(file)?.get("counts")?.let { add("counts", it) }
+ addProperty("savedAt", now)
+ }
+ write(file, out)
+ }
+ }
+
+ /** The copy of a list (no `next`: offline, the list ends where the copy ends). */
+ suspend fun list(key: String): MPage? = io { dir ->
+ read(File(dir, "lists/${name(key)}.json"))?.let(MailJson::page)?.copy(next = null)
+ }
+
+ // ─── mails ──────────────────────────────────────────────────────
+
+ suspend fun putMessage(raw: JsonObject, now: Long = System.currentTimeMillis()) {
+ val id = raw.get("id")?.takeIf { it.isJsonPrimitive }?.asString?.toLongOrNull()?.takeIf { it > 0 } ?: return
+ io { dir ->
+ write(File(dir, "msg/$id.json"), raw)
+ MailCacheLogic.overflow(File(dir, "msg").listFiles()?.map { it.name to it.lastModified() }.orEmpty(), now)
+ .forEach { File(dir, "msg/$it").delete() }
+ }
+ }
+
+ suspend fun message(id: Long): MMessage? = io { dir -> read(File(dir, "msg/$id.json"))?.let(MailJson::message) }
+
+ /** A mail's copy is gone (deleted, or moved and to be fetched again). */
+ suspend fun forgetMessages(ids: Collection) {
+ io { dir -> ids.forEach { File(dir, "msg/$it.json").delete() } }
+ }
+
+ /** An account was removed: its lists and mails go with the next load; the summary copies of all lists now. */
+ suspend fun forgetAccount(accountId: Long) {
+ io { dir ->
+ File(dir, "lists").listFiles()?.forEach { f ->
+ val o = read(f) ?: return@forEach
+ val items = o.getAsJsonArray("items") ?: return@forEach
+ val left = JsonArray()
+ items.forEach { e ->
+ val acc = e.takeIf { it.isJsonObject }?.asJsonObject?.get("accountId")?.takeIf { it.isJsonPrimitive }?.asString?.toLongOrNull()
+ if (acc != accountId) left.add(e)
+ }
+ o.add("items", left)
+ write(f, o)
+ }
+ File(dir, "msg").listFiles()?.forEach { f ->
+ val acc = read(f)?.get("accountId")?.takeIf { it.isJsonPrimitive }?.asString?.toLongOrNull()
+ if (acc == accountId) f.delete()
+ }
+ }
+ }
+
+ /** Everything of the Mail tab on this device: the copies of every login and the files opened from mails. */
+ suspend fun wipe() = withContext(Dispatchers.IO) {
+ mutex.withLock {
+ runCatching { root().deleteRecursively() }
+ runCatching { File(context.cacheDir, OPEN_DIR).deleteRecursively() }
+ runCatching { File(context.cacheDir, UPLOAD_DIR).deleteRecursively() }
+ }
+ }
+
+ /** Whether there is anything to wipe (no work at every start when the plugin was never there). */
+ fun hasData(): Boolean = root().exists() || File(context.cacheDir, OPEN_DIR).exists()
+
+ companion object {
+ private const val DIR = "mail"
+ /** Attachments opened or shared (FileProvider `mail_open`). */
+ const val OPEN_DIR = "mail-open"
+ /** Device files on their way to the server (private). */
+ const val UPLOAD_DIR = "mail-up"
+ }
+}
+
+/** What the offline copy keeps (pure, unit-tested). */
+object MailCacheLogic {
+ /** At least this many of a list, however old. */
+ const val MIN_ITEMS = 100
+ /** At most this many of a list. */
+ const val MAX_ITEMS = 3000
+ /** At most this many opened mails. */
+ const val MAX_MESSAGES = 500
+
+ private fun idOf(o: JsonObject): Long? = o.get("id")?.takeIf { it.isJsonPrimitive }?.asString?.toLongOrNull()
+
+ private fun dateOf(o: JsonObject): Long = o.get("date")?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asLong }.getOrNull() } ?: 0L
+
+ /** Pages one after the other: a newer copy of a mail replaces the older one in its place. */
+ fun mergeRaw(old: List, more: List): List {
+ val byId = more.mapNotNull { o -> idOf(o)?.let { it to o } }.toMap()
+ val oldIds = old.mapNotNull(::idOf).toHashSet()
+ return old.map { o -> idOf(o)?.let { byId[it] } ?: o } + more.filter { o -> idOf(o)?.let { it !in oldIds } ?: false }
+ }
+
+ /** The newest [MIN_ITEMS] and everything of the last 30 days, at most [MAX_ITEMS]; entries without id go. */
+ fun prune(items: List, now: Long): List {
+ val cutoff = now - MailLogic.OFFLINE_KEEP_MS
+ return items.filter { idOf(it) != null }
+ .filterIndexed { i, o -> i < MIN_ITEMS || dateOf(o) >= cutoff }
+ .take(MAX_ITEMS)
+ }
+
+ /** Copies of opened mails to delete: older than 30 days, and the oldest above [MAX_MESSAGES]. */
+ fun overflow(files: List>, now: Long): List {
+ val cutoff = now - MailLogic.OFFLINE_KEEP_MS
+ val (old, fresh) = files.partition { it.second < cutoff }
+ val extra = fresh.sortedByDescending { it.second }.drop(MAX_MESSAGES)
+ return (old + extra).map { it.first }
+ }
+}
diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt b/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt
new file mode 100644
index 00000000..ec7fcf3e
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt
@@ -0,0 +1,641 @@
+package com.gatecontrol.android.mail
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonNull
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+
+/**
+ * The Mail plugin's answers (contract C2), read from the Gson tree (pure, unit-tested): never by reflection, so R8
+ * cannot change what is read. Unknown fields are ignored, wrong ones make the entry (or the answer) null; a list keeps
+ * the entries it can read. Also the request bodies the app sends.
+ */
+object MailJson {
+
+ private val COLOR = Regex("^#[0-9A-Fa-f]{6}$")
+ private val TOKEN = Regex("^[A-Za-z0-9_.~-]{1,200}$")
+ private val CODE = Regex("^[a-z0-9_]{1,64}$")
+
+ // ─── helpers ────────────────────────────────────────────────────
+
+ private fun JsonObject.el(key: String): JsonElement? = get(key)?.takeIf { it !is JsonNull }
+
+ internal fun JsonObject.str(key: String): String? =
+ el(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString }.getOrNull() }
+
+ private fun JsonObject.long(key: String): Long? = el(key)?.takeIf { it.isJsonPrimitive }?.let { p ->
+ val prim = p.asJsonPrimitive
+ when {
+ prim.isNumber -> runCatching { prim.asDouble }.getOrNull()?.takeIf { !it.isNaN() && !it.isInfinite() }?.toLong()
+ prim.isString -> prim.asString.trim().toLongOrNull()
+ else -> null
+ }
+ }
+
+ private fun JsonObject.int(key: String): Int? = long(key)?.takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE }?.toInt()
+
+ private fun JsonObject.double(key: String): Double? = el(key)?.takeIf { it.isJsonPrimitive }?.let { p ->
+ val prim = p.asJsonPrimitive
+ when {
+ prim.isNumber -> prim.asDouble
+ prim.isString -> prim.asString.trim().toDoubleOrNull()
+ else -> null
+ }
+ }?.takeIf { !it.isNaN() && !it.isInfinite() }
+
+ private fun JsonObject.bool(key: String): Boolean? = el(key)?.takeIf { it.isJsonPrimitive }?.let { p ->
+ val prim = p.asJsonPrimitive
+ when {
+ prim.isBoolean -> prim.asBoolean
+ prim.isNumber -> prim.asInt != 0
+ prim.isString -> when (prim.asString) { "true", "1" -> true; "false", "0" -> false; else -> null }
+ else -> null
+ }
+ }
+
+ internal fun JsonObject.obj(key: String): JsonObject? = el(key)?.takeIf { it.isJsonObject }?.asJsonObject
+
+ internal fun JsonObject.arr(key: String): JsonArray? = el(key)?.takeIf { it.isJsonArray }?.asJsonArray
+
+ private fun JsonArray.objects(): List = mapNotNull { e -> e.takeIf { it.isJsonObject }?.asJsonObject }
+
+ private fun JsonArray.strings(): List = mapNotNull { e -> e.takeIf { it.isJsonPrimitive }?.asString }
+
+ /** A positive id (number or numeric string). */
+ private fun JsonObject.id(key: String): Long? = long(key)?.takeIf { it > 0 }
+
+ /** An opaque cursor (`next`): a string or a number, as the server sends it. */
+ private fun JsonObject.cursor(key: String): String? =
+ el(key)?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { it.isNotEmpty() && it.length <= 512 }
+
+ fun parse(raw: String?): JsonObject? =
+ raw?.let { runCatching { JsonParser.parseString(it) }.getOrNull() }?.takeIf { it.isJsonObject }?.asJsonObject
+
+ // ─── errors ─────────────────────────────────────────────────────
+
+ /**
+ * The code of a refusal: the plugin's `{ error: { code } }`, the core's `{ ok: false, code }`, else `http_`
+ * (0: no answer at all).
+ */
+ fun failureCode(http: Int, body: JsonObject?): String {
+ val code = body?.obj("error")?.str("code") ?: body?.str("error")?.takeIf { CODE.matches(it) } ?: body?.str("code")
+ return code?.takeIf { CODE.matches(it) } ?: "http_$http"
+ }
+
+ /** A 2xx answer that still says no (`{ error: { code } }` or `{ ok: false }`). */
+ fun isRefusal(body: JsonObject): Boolean = body.obj("error") != null || body.bool("ok") == false
+
+ /** The `hint` of a refusal (`409 auth_failed`): `{ de, en }` or a plain text. */
+ fun errorHint(body: JsonObject?, lang: String): String? {
+ val err = body?.obj("error") ?: body ?: return null
+ err.str("hint")?.let { return it.takeIf { h -> h.isNotBlank() } }
+ val h = err.obj("hint") ?: return null
+ return (if (lang == "en") h.str("en") ?: h.str("de") else h.str("de") ?: h.str("en"))?.takeIf { it.isNotBlank() }
+ }
+
+ // ─── me, accounts, providers ────────────────────────────────────
+
+ fun me(o: JsonObject): MMe? {
+ val features = o.arr("features")?.strings()?.toSet() ?: return null
+ val limits = o.obj("limits")
+ val ms = o.obj("microsoft")
+ val unread = o.obj("unread")
+ val by = unread?.obj("byAccount")?.entrySet()?.mapNotNull { (k, v) ->
+ val id = k.toLongOrNull()?.takeIf { it > 0 } ?: return@mapNotNull null
+ val n = v.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asInt ?: return@mapNotNull null
+ id to n.coerceAtLeast(0)
+ }?.toMap().orEmpty()
+ return MMe(
+ features = features,
+ limits = MLimits(
+ accountsMax = limits?.int("accountsMax") ?: 5,
+ quotaBytes = limits?.long("quotaBytes") ?: 0L,
+ usedBytes = limits?.long("usedBytes") ?: 0L,
+ retentionMonths = limits?.int("retentionMonths") ?: 12,
+ ),
+ microsoft = MMicrosoft(
+ clientId = ms?.str("clientId")?.trim()?.takeIf { it.isNotEmpty() && it.length <= 200 },
+ authority = ms?.str("authority")?.trimEnd('/')?.takeIf { it.startsWith("https://") } ?: MailMicrosoft.DEFAULT_AUTHORITY,
+ scopes = ms?.arr("scopes")?.strings()?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() } ?: MailMicrosoft.DEFAULT_SCOPES,
+ ),
+ accounts = o.arr("accounts")?.objects()?.mapNotNull(::account).orEmpty(),
+ unreadAll = unread?.int("all")?.coerceAtLeast(0) ?: 0,
+ unreadByAccount = by,
+ settings = o.obj("settings")?.let(::settings) ?: MSettings.DEFAULT,
+ )
+ }
+
+ fun settings(o: JsonObject): MSettings = MSettings(
+ threads = o.bool("threads") ?: MSettings.DEFAULT.threads,
+ swipeLeft = o.str("swipeLeft")?.takeIf { it in MSettings.SWIPES } ?: MSettings.DEFAULT.swipeLeft,
+ swipeRight = o.str("swipeRight")?.takeIf { it in MSettings.SWIPES } ?: MSettings.DEFAULT.swipeRight,
+ )
+
+ /** `{ settings }` of `PUT settings`. */
+ fun settingsAnswer(o: JsonObject): MSettings? = o.obj("settings")?.let(::settings)
+
+ fun account(o: JsonObject): MAccount? {
+ val id = o.id("id") ?: return null
+ val email = o.str("email")?.trim()?.takeIf { it.isNotEmpty() } ?: return null
+ val error = o.obj("error")
+ return MAccount(
+ id = id,
+ provider = o.str("provider") ?: MAccount.PROVIDER_IMAP,
+ email = email,
+ name = o.str("name")?.trim()?.takeIf { it.isNotEmpty() },
+ color = o.str("color")?.takeIf { COLOR.matches(it) },
+ kind = o.str("kind")?.takeIf { it == MAccount.KIND_BUSINESS } ?: MAccount.KIND_PERSONAL,
+ state = o.str("state") ?: MAccount.STATE_OK,
+ errorCode = error?.str("code"),
+ errorAt = error?.long("at"),
+ push = o.str("push")?.takeIf { it in MAccount.PUSH } ?: "all",
+ ai = o.bool("ai") ?: false,
+ attachments = o.str("attachments")?.takeIf { it in MAccount.ATTACHMENTS } ?: "off",
+ signature = o.str("signature")?.takeIf { it.isNotBlank() },
+ )
+ }
+
+ /** `{ account }`. */
+ fun accountAnswer(o: JsonObject): MAccount? = o.obj("account")?.let(::account)
+
+ /** `GET providers`: an array, or `{ items }` / `{ providers }`. */
+ fun providers(root: JsonElement): List? {
+ val arr = when {
+ root.isJsonArray -> root.asJsonArray
+ root.isJsonObject -> root.asJsonObject.let { it.arr("providers") ?: it.arr("items") }
+ else -> null
+ } ?: return null
+ return arr.objects().mapNotNull { o ->
+ val id = o.str("id")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ val hint = o.obj("hint")
+ MProvider(
+ id = id,
+ label = o.str("label")?.takeIf { it.isNotBlank() } ?: id,
+ auth = o.str("auth") ?: MProvider.AUTH_PASSWORD,
+ hintDe = hint?.str("de")?.takeIf { it.isNotBlank() },
+ hintEn = hint?.str("en")?.takeIf { it.isNotBlank() },
+ help = o.str("help")?.takeIf { it.startsWith("https://") },
+ )
+ }
+ }
+
+ private fun server(o: JsonObject?): MServer? {
+ o ?: return null
+ val host = o.str("host")?.trim()?.takeIf { it.isNotEmpty() } ?: return null
+ val port = o.int("port")?.takeIf { it in 1..65535 } ?: return null
+ return MServer(host, port, o.str("security") ?: "tls")
+ }
+
+ fun discover(o: JsonObject): MDiscover {
+ val hint = o.obj("hint")
+ return MDiscover(
+ provider = o.str("provider"),
+ auth = o.str("auth"),
+ imap = server(o.obj("imap")),
+ smtp = server(o.obj("smtp")),
+ hintDe = hint?.str("de") ?: o.str("hint"),
+ hintEn = hint?.str("en"),
+ )
+ }
+
+ // ─── folders, lists ─────────────────────────────────────────────
+
+ /** `GET folders` → the folders per account, in the server's order. */
+ fun folders(o: JsonObject): Map>? {
+ val accounts = o.arr("accounts") ?: return null
+ val out = LinkedHashMap>()
+ for (a in accounts.objects()) {
+ val accountId = a.id("accountId") ?: continue
+ out[accountId] = a.arr("folders")?.objects()?.mapNotNull { f ->
+ val id = f.id("id") ?: return@mapNotNull null
+ MFolder(
+ id = id,
+ accountId = accountId,
+ name = f.str("name")?.takeIf { it.isNotBlank() } ?: f.str("path") ?: return@mapNotNull null,
+ path = f.str("path") ?: "",
+ role = f.str("role")?.takeIf { it in ROLES },
+ parentId = f.id("parentId"),
+ unread = f.int("unread")?.coerceAtLeast(0) ?: 0,
+ total = f.int("total")?.coerceAtLeast(0) ?: 0,
+ )
+ }.orEmpty()
+ }
+ return out
+ }
+
+ private val ROLES = setOf(MFolder.ROLE_INBOX, MFolder.ROLE_SENT, MFolder.ROLE_DRAFTS, MFolder.ROLE_ARCHIVE, MFolder.ROLE_JUNK, MFolder.ROLE_TRASH)
+
+ fun address(o: JsonObject): MAddress? {
+ val email = (o.str("email") ?: o.str("address"))?.trim()?.takeIf { it.isNotEmpty() } ?: return null
+ return MAddress(o.str("name")?.trim()?.takeIf { it.isNotEmpty() }, email)
+ }
+
+ /** Addresses as objects `{ name, email }` or as plain strings (`Name ` or `a@b`). */
+ fun addresses(arr: JsonArray?): List = arr?.mapNotNull { e ->
+ when {
+ e.isJsonObject -> address(e.asJsonObject)
+ e.isJsonPrimitive -> MailLogic.parseAddress(e.asString)
+ else -> null
+ }
+ }.orEmpty()
+
+ fun summary(o: JsonObject): MSummary? {
+ val id = o.id("id") ?: return null
+ val accountId = o.id("accountId") ?: return null
+ val from = o.obj("from")?.let(::address) ?: MAddress(o.obj("from")?.str("name"), "")
+ return MSummary(
+ id = id,
+ accountId = accountId,
+ folderId = o.id("folderId"),
+ threadId = o.id("threadId"),
+ threadCount = o.int("threadCount")?.coerceAtLeast(1) ?: 1,
+ from = from,
+ to = addresses(o.arr("to")),
+ subject = o.str("subject") ?: "",
+ snippet = o.str("snippet")?.take(400) ?: "",
+ date = o.long("date") ?: 0L,
+ unread = o.bool("unread") ?: false,
+ flagged = o.bool("flagged") ?: false,
+ answered = o.bool("answered") ?: false,
+ hasAttachments = o.bool("hasAttachments") ?: false,
+ tab = o.str("tab")?.takeIf { it == MSummary.TAB_OTHER } ?: MSummary.TAB_RELEVANT,
+ savedToDrive = o.bool("savedToDrive") ?: false,
+ aiSummary = o.obj("ai")?.str("summary")?.takeIf { it.isNotBlank() },
+ )
+ }
+
+ fun summaries(arr: JsonArray?): List = arr?.objects()?.mapNotNull(::summary).orEmpty()
+
+ fun page(o: JsonObject): MPage? {
+ val items = o.arr("items") ?: return null
+ val counts = o.obj("counts")?.let { c -> MCounts(c.int("relevant")?.coerceAtLeast(0) ?: 0, c.int("other")?.coerceAtLeast(0) ?: 0) }
+ return MPage(summaries(items), o.cursor("next"), counts)
+ }
+
+ fun searchPage(o: JsonObject): MSearchPage? {
+ val items = o.arr("items") ?: return null
+ return MSearchPage(summaries(items), o.cursor("next"), o.bool("remote") ?: false)
+ }
+
+ /** `{ ok, items? }` of the bulk actions: the changed summaries (empty when the server sends none). */
+ fun changedItems(o: JsonObject): List = summaries(o.arr("items"))
+
+ /** `GET threads/` → oldest first. */
+ fun thread(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull(::message)
+
+ // ─── one message ────────────────────────────────────────────────
+
+ fun suggestion(o: JsonObject?): MSuggestion? {
+ o ?: return null
+ val folderId = o.id("folderId") ?: return null
+ return MSuggestion(
+ folderId = folderId,
+ areaId = o.str("areaId"),
+ path = o.str("path")?.takeIf { it.isNotBlank() },
+ where = o.str("where")?.takeIf { it.isNotBlank() },
+ name = o.str("name")?.takeIf { it.isNotBlank() },
+ confidence = (o.double("confidence") ?: 0.0).coerceIn(0.0, 1.0),
+ reason = o.str("reason") ?: "ai",
+ )
+ }
+
+ fun savedTo(o: JsonObject?): MSavedTo? {
+ o ?: return null
+ val node = o.id("nodeId") ?: return null
+ return MSavedTo(node, o.str("where") ?: "")
+ }
+
+ fun attachment(o: JsonObject): MAttachment? {
+ val id = o.id("id") ?: return null
+ return MAttachment(
+ id = id,
+ name = o.str("name")?.takeIf { it.isNotBlank() } ?: "attachment",
+ mime = o.str("mime")?.takeIf { it.isNotBlank() } ?: "application/octet-stream",
+ size = o.long("size")?.coerceAtLeast(0) ?: 0L,
+ inline = o.bool("inline") ?: false,
+ cid = o.str("cid")?.trim()?.removePrefix("<")?.removeSuffix(">")?.takeIf { it.isNotEmpty() },
+ savedTo = savedTo(o.obj("savedTo")),
+ suggestion = suggestion(o.obj("suggestion")),
+ )
+ }
+
+ fun message(o: JsonObject): MMessage? {
+ val summary = summary(o) ?: return null
+ val unsub = o.obj("listUnsubscribe")?.let { u ->
+ MUnsubscribe(u.str("url")?.takeIf { it.startsWith("https://") || it.startsWith("http://") }, u.str("mail")?.takeIf { it.isNotBlank() })
+ }?.takeIf { it.url != null || it.mail != null }
+ return MMessage(
+ summary = summary,
+ cc = addresses(o.arr("cc")),
+ bcc = addresses(o.arr("bcc")),
+ replyTo = addresses(o.arr("replyTo")),
+ html = o.str("html")?.takeIf { it.isNotBlank() },
+ text = o.str("text"),
+ imagesBlocked = o.bool("imagesBlocked") ?: false,
+ trackers = o.int("trackers")?.coerceAtLeast(0) ?: 0,
+ attachments = o.arr("attachments")?.objects()?.mapNotNull(::attachment).orEmpty(),
+ unsubscribe = unsub,
+ bodyMissing = o.bool("bodyMissing") ?: false,
+ )
+ }
+
+ /** `{ mediaId, name, mime, size }`. */
+ fun attachmentMedia(o: JsonObject): MAttachmentMedia? {
+ val media = o.str("mediaId")?.takeIf { TOKEN.matches(it) } ?: return null
+ return MAttachmentMedia(media, o.str("name") ?: "attachment", o.str("mime") ?: "application/octet-stream", o.long("size") ?: 0L)
+ }
+
+ /** A picture of the HTML: inline bytes (`img/…`) or a media file (`cid/…`). */
+ class Picture(val mime: String, val data: ByteArray?, val mediaId: String?)
+
+ /** Raster pictures only: SVG (it can carry script) and anything else is refused. */
+ private val PICTURE_MIME = Regex("^image/(png|jpe?g|gif|webp|bmp|x-icon|vnd\\.microsoft\\.icon|avif|heic|heif)$")
+
+ /**
+ * `GET img/` → `{ mime, size, data }` (Base64) or `GET cid//` → `{ mediaId, mime, size, url }`; null
+ * when it is no picture the WebView may show.
+ */
+ fun picture(o: JsonObject): Picture? {
+ val mime = o.str("mime")?.lowercase()?.substringBefore(';')?.trim()?.takeIf { PICTURE_MIME.matches(it) } ?: return null
+ val data = o.str("data")?.let { runCatching { java.util.Base64.getMimeDecoder().decode(it) }.getOrNull() }
+ if (data != null) return if (data.isEmpty() || data.size > 6 * 1024 * 1024) null else Picture(mime, data, null)
+ val media = o.str("mediaId")?.takeIf { TOKEN.matches(it) } ?: return null
+ return Picture(mime, null, media)
+ }
+
+ // ─── AI ─────────────────────────────────────────────────────────
+
+ fun ai(o: JsonObject): MAi? {
+ val status = o.str("status") ?: return null
+ if (status != "done") return MAi.NONE
+ return MAi(
+ done = true,
+ summary = o.str("summary")?.takeIf { it.isNotBlank() },
+ todos = o.arr("todos")?.objects()?.mapNotNull { t ->
+ val text = t.str("text")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ MTodo(
+ kind = t.str("kind") ?: MTodo.TASK,
+ text = text,
+ date = t.str("date")?.takeIf { MailLogic.isIsoDate(it) },
+ time = t.str("time")?.takeIf { MailLogic.isTime(it) },
+ amount = t.double("amount"),
+ currency = t.str("currency"),
+ )
+ }.orEmpty(),
+ replies = o.arr("replies")?.objects()?.mapNotNull { r ->
+ val text = r.str("text")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ MReply(r.str("label")?.takeIf { it.isNotBlank() } ?: text.take(40), text)
+ }.orEmpty(),
+ )
+ }
+
+ /** `{ where }` of a to-do put into the family calendar. */
+ fun where(o: JsonObject): String? = o.str("where") ?: ""
+
+ // ─── drafts, sending ────────────────────────────────────────────
+
+ fun draft(o: JsonObject): MDraft? {
+ val id = o.id("id") ?: return null
+ return MDraft(
+ id = id,
+ accountId = o.id("accountId") ?: 0L,
+ to = addresses(o.arr("to")),
+ cc = addresses(o.arr("cc")),
+ bcc = addresses(o.arr("bcc")),
+ subject = o.str("subject") ?: "",
+ html = o.str("html") ?: "",
+ attachments = o.arr("attachments")?.objects()?.mapNotNull { a ->
+ val aid = a.id("id") ?: return@mapNotNull null
+ MDraftAttachment(
+ id = aid,
+ name = a.str("name") ?: "attachment",
+ size = a.long("size")?.coerceAtLeast(0) ?: 0L,
+ mime = a.str("mime") ?: "application/octet-stream",
+ asLink = a.str("as") == "link",
+ url = a.str("url")?.takeIf { it.startsWith("https://") || it.startsWith("http://") },
+ )
+ }.orEmpty(),
+ warnings = o.arr("warnings")?.objects()?.mapNotNull { w ->
+ val kind = w.str("kind") ?: return@mapNotNull null
+ MWarning(kind, w.str("text") ?: "")
+ }.orEmpty(),
+ )
+ }
+
+ /** `{ draft }`. */
+ fun draftAnswer(o: JsonObject): MDraft? = o.obj("draft")?.let(::draft) ?: draft(o)
+
+ fun send(o: JsonObject): MSend? {
+ val id = o.id("outboxId") ?: return null
+ return MSend(id, o.long("undoUntil"), o.long("sendAt"))
+ }
+
+ /** `{ draftId }` of `POST outbox//cancel`. */
+ fun cancelled(o: JsonObject): Long? = o.id("draftId")
+
+ fun outbox(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { i ->
+ val id = i.id("id") ?: return@mapNotNull null
+ MOutboxItem(
+ id = id,
+ draftId = i.id("draftId"),
+ accountId = i.id("accountId"),
+ subject = i.str("subject") ?: "",
+ sendAt = i.long("sendAt"),
+ state = i.str("state") ?: "waiting",
+ error = i.str("error") ?: i.obj("error")?.str("code"),
+ )
+ }
+
+ fun contacts(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { c ->
+ val email = c.str("email")?.trim()?.takeIf { it.contains('@') } ?: return@mapNotNull null
+ MContact(c.str("name")?.trim()?.takeIf { it.isNotEmpty() }, email, c.str("source") ?: "recent")
+ }
+
+ // ─── Drive ──────────────────────────────────────────────────────
+
+ fun suggest(o: JsonObject): MSuggest = MSuggest(suggestion(o.obj("suggestion")), o.bool("duplicate") ?: false)
+
+ /** `{ savedTo }`. */
+ fun savedAnswer(o: JsonObject): MSavedTo? = savedTo(o.obj("savedTo"))
+
+ fun saveAll(o: JsonObject): MSaveAll = MSaveAll(
+ saved = o.arr("saved")?.objects()?.mapNotNull { s -> s.id("attachmentId")?.let { a -> savedTo(s.obj("savedTo"))?.let { a to it } } }.orEmpty(),
+ failed = o.arr("failed")?.objects()?.mapNotNull { f -> f.id("attachmentId")?.let { it to (f.str("code") ?: "failed") } }.orEmpty(),
+ )
+
+ fun filing(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { i ->
+ val id = i.id("id") ?: return@mapNotNull null
+ MFilingItem(
+ id = id,
+ messageId = i.id("messageId") ?: return@mapNotNull null,
+ attachmentId = i.id("attachmentId") ?: return@mapNotNull null,
+ name = i.str("name") ?: "attachment",
+ from = i.obj("from")?.let(::address) ?: i.str("from")?.let(MailLogic::parseAddress),
+ date = i.long("date"),
+ suggestion = suggestion(i.obj("suggestion")),
+ )
+ }
+
+ // ─── live ───────────────────────────────────────────────────────
+
+ /** Realtime event `mail` (or the plugin's data under `mail`): `{ kind: 'changed', accountId, folderId }`. */
+ fun changed(data: JsonElement?): MChanged? {
+ val o = data?.takeIf { it.isJsonObject }?.asJsonObject ?: return null
+ val root = o.obj("mail") ?: o
+ if (root.str("kind") != "changed") return null
+ return MChanged(root.id("accountId"), root.id("folderId"))
+ }
+
+ // ─── request bodies ─────────────────────────────────────────────
+
+ fun idsBody(ids: Collection): JsonObject = JsonObject().apply {
+ add("ids", JsonArray().apply { ids.forEach { add(it) } })
+ }
+
+ fun flagsBody(ids: Collection, unread: Boolean?, flagged: Boolean?): JsonObject = idsBody(ids).apply {
+ unread?.let { addProperty("unread", it) }
+ flagged?.let { addProperty("flagged", it) }
+ }
+
+ fun moveBody(ids: Collection, folderId: Long): JsonObject = idsBody(ids).apply { addProperty("folderId", folderId) }
+
+ fun tabBody(ids: Collection, tab: String): JsonObject = idsBody(ids).apply { addProperty("tab", tab) }
+
+ fun settingsBody(threads: Boolean? = null, swipeLeft: String? = null, swipeRight: String? = null): JsonObject = JsonObject().apply {
+ threads?.let { addProperty("threads", it) }
+ swipeLeft?.takeIf { it in MSettings.SWIPES }?.let { addProperty("swipeLeft", it) }
+ swipeRight?.takeIf { it in MSettings.SWIPES }?.let { addProperty("swipeRight", it) }
+ }
+
+ private fun serverBody(s: MServer): JsonObject = JsonObject().apply {
+ addProperty("host", s.host)
+ addProperty("port", s.port)
+ addProperty("security", s.security)
+ }
+
+ fun accountBody(
+ provider: String,
+ email: String,
+ password: String,
+ username: String?,
+ name: String?,
+ color: String?,
+ kind: String?,
+ imap: MServer?,
+ smtp: MServer?,
+ ): JsonObject = JsonObject().apply {
+ addProperty("provider", provider)
+ addProperty("email", email.trim())
+ addProperty("password", password)
+ username?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("username", it) }
+ name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) }
+ color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) }
+ kind?.let { addProperty("kind", it) }
+ imap?.let { add("imap", serverBody(it)) }
+ smtp?.let { add("smtp", serverBody(it)) }
+ }
+
+ fun microsoftBody(code: String, verifier: String, redirectUri: String, name: String?, color: String?, kind: String?): JsonObject = JsonObject().apply {
+ addProperty("code", code)
+ addProperty("codeVerifier", verifier)
+ addProperty("redirectUri", redirectUri)
+ name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) }
+ color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) }
+ kind?.let { addProperty("kind", it) }
+ }
+
+ /** `PATCH accounts/`: only what is given. */
+ fun accountPatch(
+ name: String? = null,
+ color: String? = null,
+ kind: String? = null,
+ push: String? = null,
+ ai: Boolean? = null,
+ attachments: String? = null,
+ signature: String? = null,
+ password: String? = null,
+ ): JsonObject = JsonObject().apply {
+ name?.let { addProperty("name", it.trim()) }
+ color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) }
+ kind?.let { addProperty("kind", it) }
+ push?.takeIf { it in MAccount.PUSH }?.let { addProperty("push", it) }
+ ai?.let { addProperty("ai", it) }
+ attachments?.takeIf { it in MAccount.ATTACHMENTS }?.let { addProperty("attachments", it) }
+ signature?.let { addProperty("signature", it) }
+ password?.let { addProperty("password", it) }
+ }
+
+ private fun addressArray(list: List): JsonArray = JsonArray().apply {
+ list.forEach { a ->
+ add(JsonObject().apply {
+ a.name?.let { addProperty("name", it) }
+ addProperty("email", a.email)
+ })
+ }
+ }
+
+ fun att(a: MAtt): JsonObject = JsonObject().apply {
+ when (a) {
+ is MAtt.Kept -> addProperty("id", a.id)
+ is MAtt.Upload -> {
+ addProperty("mediaId", a.mediaId)
+ addProperty("name", a.name)
+ }
+ is MAtt.Forwarded -> {
+ addProperty("messageId", a.messageId)
+ addProperty("attachmentId", a.attachmentId)
+ }
+ is MAtt.Drive -> {
+ addProperty("driveNodeId", a.nodeId)
+ addProperty("as", if (a.asLink) "link" else "copy")
+ if (a.asLink) {
+ a.expiresDays?.coerceIn(1, 90)?.let { addProperty("expiresDays", it) }
+ a.password?.takeIf { it.isNotEmpty() }?.let { addProperty("password", it) }
+ }
+ }
+ }
+ }
+
+ /**
+ * `POST drafts` / `PUT drafts/`. With [mode] (reply, replyAll, forward) and [replyTo] the server fills
+ * recipients, subject and quote in when they are left out: null fields are not sent.
+ */
+ fun draftBody(
+ accountId: Long?,
+ to: List?,
+ cc: List?,
+ bcc: List?,
+ subject: String?,
+ html: String?,
+ replyTo: Long? = null,
+ mode: String? = null,
+ attachments: List = emptyList(),
+ ): JsonObject = JsonObject().apply {
+ accountId?.takeIf { it > 0 }?.let { addProperty("accountId", it) }
+ to?.let { add("to", addressArray(it)) }
+ cc?.let { add("cc", addressArray(it)) }
+ bcc?.let { add("bcc", addressArray(it)) }
+ subject?.let { addProperty("subject", it) }
+ html?.let { addProperty("html", it) }
+ replyTo?.takeIf { it > 0 }?.let { addProperty("replyTo", it) }
+ mode?.takeIf { it in MODES }?.let { addProperty("mode", it) }
+ add("attachments", JsonArray().apply { attachments.forEach { add(att(it)) } })
+ }
+
+ val MODES = setOf("reply", "replyAll", "forward")
+
+ fun sendBody(sendAt: Long?): JsonObject = JsonObject().apply { sendAt?.let { addProperty("sendAt", it) } }
+
+ fun imagesBody(sender: Boolean): JsonObject = JsonObject().apply { addProperty("allow", if (sender) "sender" else "once") }
+
+ fun todoBody(): JsonObject = JsonObject().apply { addProperty("action", "family_calendar") }
+
+ fun driveSaveBody(folderId: Long?, name: String?, remember: Boolean?): JsonObject = JsonObject().apply {
+ folderId?.let { addProperty("folderId", it) }
+ name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) }
+ remember?.let { addProperty("remember", it) }
+ }
+
+ fun discoverBody(email: String): JsonObject = JsonObject().apply { addProperty("email", email.trim()) }
+}
diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt
new file mode 100644
index 00000000..7c822070
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt
@@ -0,0 +1,396 @@
+package com.gatecontrol.android.mail
+
+import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.temporal.ChronoUnit
+
+/**
+ * The Mail tab's rules (pure, unit-tested): which list a box shows, the swipe gestures, the undo window after
+ * sending, the time column, addresses, the HTML shown in the WebView (embedded pictures, the requests it may make)
+ * and the body of a draft.
+ */
+object MailLogic {
+
+ /** "Alle Posteingänge". */
+ const val BOX_ALL = "all"
+
+ /** The undo window the server keeps after `send` (contract: 10 s). */
+ const val UNDO_MS = 10_000L
+
+ /** Offline: at least this much of the inbox stays on the device. */
+ const val OFFLINE_KEEP_MS = 30L * 24 * 60 * 60 * 1000
+
+ // ─── boxes and lists ────────────────────────────────────────────
+
+ /** A box: all inboxes ([accountId] null) or one account, optionally one of its folders. */
+ data class Box(val accountId: Long? = null, val folderId: Long? = null) {
+ val all: Boolean get() = accountId == null
+ /** An inbox (all, or an account without a folder, or its inbox folder): has Relevant / Sonstige. */
+ fun isInbox(folders: List): Boolean =
+ folderId == null || folders.firstOrNull { it.id == folderId }?.role == MFolder.ROLE_INBOX
+
+ /** `box` of `GET messages`. */
+ val query: String get() = accountId?.toString() ?: BOX_ALL
+
+ /** The key of its offline copy. */
+ fun key(tab: String): String = "box-${query}-${folderId ?: 0}-$tab"
+ }
+
+ /** The query of `GET messages` for a box. */
+ fun listQuery(
+ box: Box,
+ tab: String,
+ inbox: Boolean,
+ before: String? = null,
+ unread: Boolean = false,
+ flagged: Boolean = false,
+ attachments: Boolean = false,
+ limit: Int = 50,
+ ): Map = buildMap {
+ put("box", box.query)
+ if (box.accountId != null) box.folderId?.let { put("folder", it.toString()) }
+ if (inbox) put("tab", tab)
+ if (unread) put("unread", "1")
+ if (flagged) put("flagged", "1")
+ if (attachments) put("attachments", "1")
+ before?.let { put("before", it) }
+ put("limit", limit.coerceIn(1, 100).toString())
+ }
+
+ /** Pages one after the other: the newer page's items replace the same ids of an older one. */
+ fun merge(old: List, more: List): List {
+ val byId = more.associateBy { it.id }
+ val oldIds = old.mapTo(HashSet()) { it.id }
+ return old.map { byId[it.id] ?: it } + more.filter { it.id !in oldIds }.distinctBy { it.id }
+ }
+
+ /** The list after a change to some mails: [changed] replace theirs, [removed] go. */
+ fun apply(list: List, changed: List, removed: Set = emptySet()): List {
+ val byId = changed.associateBy { it.id }
+ return list.filter { it.id !in removed }.map { byId[it.id] ?: it }
+ }
+
+ /** Whether a mail still belongs to the list it is in after [action] (archive, delete, junk, move take it away). */
+ fun leavesList(action: String): Boolean = action in setOf(SWIPE_ARCHIVE, SWIPE_DELETE, "junk", "move", "tab")
+
+ // ─── swipes ─────────────────────────────────────────────────────
+
+ const val SWIPE_ARCHIVE = MSettings.SWIPE_ARCHIVE
+ const val SWIPE_DELETE = MSettings.SWIPE_DELETE
+ const val SWIPE_READ = MSettings.SWIPE_READ
+
+ /** What a swipe does (null: nothing, the row does not move). `read` flips read / unread. */
+ fun swipe(setting: String?): String? = setting?.takeIf { it == SWIPE_ARCHIVE || it == SWIPE_DELETE || it == SWIPE_READ }
+
+ // ─── undo after sending ─────────────────────────────────────────
+
+ /**
+ * How long "Rückgängig" stays after `send` answered at [now]: the server's [undoUntil] when it lies within the
+ * contract's 10 s, else (clocks apart) 10 s less what the call took ([callMs]). Never negative.
+ */
+ fun undoWindow(undoUntil: Long?, now: Long, callMs: Long = 0L): Long {
+ val left = undoUntil?.let { it - now }
+ return when {
+ left != null && left in 1..UNDO_MS -> left
+ else -> (UNDO_MS - callMs.coerceAtLeast(0)).coerceIn(0, UNDO_MS)
+ }
+ }
+
+ /** Whether the send was planned for later (not just the undo time). */
+ fun planned(sendAt: Long?, now: Long): Boolean = sendAt != null && sendAt - now > UNDO_MS + 5_000
+
+ /** Suggested times for "Senden planen": tomorrow 08:00, Monday 08:00 (when tomorrow is not Monday). */
+ fun planChoices(now: Long, zone: ZoneId): List {
+ val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate()
+ val tomorrow = today.plusDays(1)
+ val out = mutableListOf(tomorrow.atTime(8, 0).atZone(zone).toInstant().toEpochMilli())
+ var monday = today.plusDays(1)
+ while (monday.dayOfWeek != java.time.DayOfWeek.MONDAY) monday = monday.plusDays(1)
+ if (monday != tomorrow) out += monday.atTime(8, 0).atZone(zone).toInstant().toEpochMilli()
+ return out
+ }
+
+ // ─── the time column ────────────────────────────────────────────
+
+ enum class When { TIME, YESTERDAY, WEEKDAY, DATE, DATE_YEAR }
+
+ /** How a mail's [date] is shown on [now]: the time today, "Gestern", the weekday this week, else the date. */
+ fun whenOf(date: Long, now: Long, zone: ZoneId): When {
+ val d = Instant.ofEpochMilli(date).atZone(zone).toLocalDate()
+ val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate()
+ val days = ChronoUnit.DAYS.between(d, today)
+ return when {
+ days <= 0L -> When.TIME
+ days == 1L -> When.YESTERDAY
+ days < 7L -> When.WEEKDAY
+ d.year == today.year -> When.DATE
+ else -> When.DATE_YEAR
+ }
+ }
+
+ // ─── addresses, names ───────────────────────────────────────────
+
+ private val ANGLE = Regex("^\\s*\"?([^\"<]*?)\"?\\s*<\\s*([^<>\\s]+@[^<>\\s]+)\\s*>\\s*$")
+ private val EMAIL = Regex("^[^@\\s<>,;\"]+@[^@\\s<>,;\"]+\\.[^@\\s<>,;\"]+$")
+
+ fun isEmail(s: String): Boolean = EMAIL.matches(s.trim())
+
+ /** `Name ` or `a@b.de` → the address; null when there is none. */
+ fun parseAddress(raw: String?): MAddress? {
+ val s = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null
+ ANGLE.matchEntire(s)?.let { m ->
+ val email = m.groupValues[2].trim()
+ if (!isEmail(email)) return null
+ return MAddress(m.groupValues[1].trim().takeIf { it.isNotEmpty() }, email)
+ }
+ return if (isEmail(s)) MAddress(null, s) else null
+ }
+
+ /** What was typed into a recipient field: addresses separated by `,` `;` or spaces; the rest is [Parsed.rest]. */
+ data class Parsed(val addresses: List, val rest: String)
+
+ fun parseRecipients(input: String): Parsed {
+ val parts = input.split(',', ';', '\n')
+ val out = ArrayList()
+ val rest = ArrayList()
+ for (p in parts) {
+ val t = p.trim()
+ if (t.isEmpty()) continue
+ val one = parseAddress(t)
+ if (one != null) {
+ out += one
+ continue
+ }
+ // "a@b.de c@d.de" without commas
+ val words = t.split(Regex("\\s+"))
+ if (words.size > 1 && words.all { isEmail(it) }) out += words.map { MAddress(null, it) } else rest += t
+ }
+ return Parsed(out.distinctBy { it.email.lowercase() }, rest.joinToString(", "))
+ }
+
+ /** Two letters for the round avatar: first letters of the first two words, else of the address. */
+ fun initials(name: String?, email: String): String {
+ val words = name?.trim()?.split(Regex("\\s+"))?.filter { w -> w.firstOrNull()?.isLetterOrDigit() == true }.orEmpty()
+ val letters = when {
+ words.size >= 2 -> "${words[0].first()}${words[1].first()}"
+ words.size == 1 -> words[0].take(1)
+ else -> email.trim().take(1)
+ }
+ return letters.uppercase().ifEmpty { "?" }
+ }
+
+ /** `#RRGGBB` → ARGB, null when it is not one. */
+ fun color(hex: String?): Long? {
+ val h = hex?.takeIf { it.length == 7 && it.startsWith('#') } ?: return null
+ return h.substring(1).toLongOrNull(16)?.let { 0xFF000000L or it }
+ }
+
+ /** Colors offered for a new account (as the mockups show them). */
+ val COLORS = listOf("#F5B544", "#79B8FF", "#FF8080", "#B79CFF", "#46E0A0", "#FF9F6E")
+
+ /** The next color for a new account: the first not in use, else round robin. */
+ fun nextColor(used: List): String =
+ COLORS.firstOrNull { c -> used.none { it.equals(c, ignoreCase = true) } } ?: COLORS[used.size % COLORS.size]
+
+ /** Quota in percent (0…100), null without a quota. */
+ fun quotaPercent(used: Long, quota: Long): Int? = if (quota <= 0) null else ((used.coerceAtLeast(0) * 100.0) / quota).toInt().coerceIn(0, 100)
+
+ /** A Drive suggestion's sureness in percent. */
+ fun percent(confidence: Double): Int = (confidence.coerceIn(0.0, 1.0) * 100).toInt()
+
+ /** Kept automatically by Drive (contract: rule, learned or ≥ 0.9). */
+ fun autoFiled(s: MSuggestion): Boolean = s.reason == "rule" || s.reason == "learned" || s.confidence >= 0.9
+
+ // ─── dates of the AI's to-dos ───────────────────────────────────
+
+ private val ISO_DATE = Regex("^\\d{4}-\\d{2}-\\d{2}$")
+ private val TIME = Regex("^([01]\\d|2[0-3]):[0-5]\\d$")
+
+ fun isIsoDate(s: String): Boolean = ISO_DATE.matches(s) && runCatching { LocalDate.parse(s) }.isSuccess
+
+ fun isTime(s: String): Boolean = TIME.matches(s)
+
+ /** Start of a to-do in the device's calendar (ms), null without a date. All day when there is no time. */
+ fun todoStart(todo: MTodo, zone: ZoneId): Pair? {
+ val date = todo.date?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return null
+ val time = todo.time?.let { runCatching { java.time.LocalTime.parse(it) }.getOrNull() }
+ return if (time != null) {
+ date.atTime(time).atZone(zone).toInstant().toEpochMilli() to false
+ } else {
+ // all-day events are given in UTC midnight
+ date.atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli() to true
+ }
+ }
+
+ /** A to-do the calendar can take (a deadline or an event with a date). */
+ fun calendarTodo(todo: MTodo): Boolean = (todo.kind == MTodo.DEADLINE || todo.kind == MTodo.EVENT) && todo.date != null
+
+ // ─── HTML in the WebView ────────────────────────────────────────
+
+ /** The address the mail's HTML is loaded under; its requests are answered by the app (never the network). */
+ const val BASE_HOST = "mail.gatecontrol.invalid"
+ const val BASE_URL = "https://$BASE_HOST/"
+
+ private val IMG_TOKEN = Regex("^[A-Za-z0-9_.~-]{1,300}$")
+ private val CID_SRC = Regex("(?i)(\\b(?:src|background)\\s*=\\s*)([\"']?)cid:([^\"'\\s>]+)\\2")
+
+ /**
+ * `cid:` pictures → `cid//` (the plugin's route, contract C2); a `cid:` without an
+ * attachment stays (it loads nothing).
+ */
+ fun rewriteCid(html: String, messageId: Long, attachments: List): String {
+ val byCid = attachments.filter { it.cid != null }.associateBy { it.cid!!.lowercase() }
+ return CID_SRC.replace(html) { m ->
+ val cid = java.net.URLDecoder.decode(m.groupValues[3].replace("+", "%2B"), "UTF-8").removePrefix("<").removeSuffix(">")
+ val a = byCid[cid.lowercase()] ?: return@replace m.value
+ val q = m.groupValues[2].ifEmpty { "\"" }
+ "${m.groupValues[1]}${q}cid/$messageId/${a.id}$q"
+ }
+ }
+
+ /**
+ * The plugin route a WebView request may load (`img/`, `cid//`), null for anything
+ * else: such a request is answered empty, the mail never reaches the network by itself.
+ */
+ fun interceptPath(url: String): String? {
+ val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return null
+ if (uri.scheme != "https" || uri.host != BASE_HOST || uri.rawQuery != null) return null
+ val parts = (uri.rawPath ?: return null).trimStart('/').split('/')
+ return when {
+ parts.size == 2 && parts[0] == "img" && IMG_TOKEN.matches(parts[1]) -> "img/${parts[1]}"
+ parts.size == 3 && parts[0] == "cid" && parts[1].toLongOrNull()?.let { it > 0 } == true &&
+ parts[2].toLongOrNull()?.let { it > 0 } == true -> "cid/${parts[1]}/${parts[2]}"
+ else -> null
+ }
+ }
+
+ /** Links a tap may open outside the app (the rest is ignored). */
+ fun externalLink(url: String): Boolean {
+ val scheme = runCatching { java.net.URI(url).scheme?.lowercase() }.getOrNull()
+ return scheme == "https" || scheme == "http" || scheme == "mailto" || scheme == "tel"
+ }
+
+ /** `mailto:a@b.de?subject=…` → the address (null for anything else). */
+ fun mailtoAddress(url: String): MAddress? {
+ if (!url.startsWith("mailto:", ignoreCase = true)) return null
+ val addr = java.net.URLDecoder.decode(url.substring(7).substringBefore('?'), "UTF-8")
+ return parseRecipients(addr).addresses.firstOrNull()
+ }
+
+ /**
+ * The page the WebView shows: the mail's (already cleaned) HTML with a strict content policy (no scripts, no
+ * frames, pictures only from the app's own address) and a width that fits the phone.
+ */
+ fun page(html: String, dark: Boolean = false): String {
+ val csp = "default-src 'none'; img-src $BASE_URL data:; style-src 'unsafe-inline'; font-src data:; " +
+ "script-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'"
+ val colors = if (dark) "color-scheme: light;" else ""
+ return "" +
+ "" +
+ "" +
+ "" +
+ html + ""
+ }
+
+ // ─── the body of a draft ────────────────────────────────────────
+
+ /** Marks the part of a draft's HTML the person typed in this app (the rest is the quote below it). */
+ private const val TEXT_OPEN = ""
+ private const val TEXT_CLOSE = "
"
+
+ fun escape(s: String): String = buildString(s.length + 16) {
+ for (c in s) when (c) {
+ '&' -> append("&")
+ '<' -> append("<")
+ '>' -> append(">")
+ '"' -> append(""")
+ '\'' -> append("'")
+ else -> append(c)
+ }
+ }
+
+ /** Plain text → HTML (lines kept). */
+ fun textToHtml(text: String): String = escape(text.replace("\r\n", "\n")).replace("\n", "
")
+
+ /** The draft's HTML: the typed [text] and, below it, the [quote] (the server's HTML, unchanged). */
+ fun composeHtml(text: String, quote: String): String = TEXT_OPEN + textToHtml(text) + TEXT_CLOSE + quote
+
+ /** A draft's HTML taken apart again: what was typed here and the quote. Another app's draft: all of it as text. */
+ fun splitDraft(html: String): Pair {
+ val start = html.indexOf(TEXT_OPEN)
+ val end = html.indexOf(TEXT_CLOSE)
+ if (start == 0 && end > start) {
+ val inner = html.substring(TEXT_OPEN.length, end)
+ return htmlToText(inner) to html.substring(end + TEXT_CLOSE.length)
+ }
+ if (start < 0 && html.isNotBlank() && looksQuoted(html)) return "" to html
+ return htmlToText(html) to ""
+ }
+
+ /** The server's prefilled answer or forward starts with its quote (contract: Zitat). */
+ private fun looksQuoted(html: String): Boolean {
+ val h = html.trimStart().lowercase()
+ return h.startsWith("
")
+ }
+
+ private val TAG = Regex("<[^>]*>")
+ private val BR = Regex("(?i)
")
+ private val BLOCK_END = Regex("(?i)(p|div|li|tr|h[1-6])>")
+
+ /** Rough HTML → text (lines kept, tags dropped, the common entities decoded). */
+ fun htmlToText(html: String): String = html
+ .replace(BR, "\n")
+ .replace(BLOCK_END, "\n")
+ .replace(TAG, "")
+ .replace(" ", " ")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(""", "\"")
+ .replace("'", "'")
+ .replace("&", "&")
+ .trimEnd('\n')
+
+ /** A new mail's text with the account's signature below (`-- ` line as usual). */
+ fun withSignature(text: String, signature: String?): String {
+ val sig = signature?.trim()?.takeIf { it.isNotEmpty() } ?: return text
+ if (text.contains(sig)) return text
+ return text + "\n\n-- \n" + sig
+ }
+
+ /** A suggestion of the AI put above what the server prefilled. */
+ fun withReply(reply: String, text: String): String = if (text.isBlank()) reply else reply + "\n\n" + text
+
+ // ─── attachments ────────────────────────────────────────────────
+
+ /** Short type label of the file icon (`PDF`, `DOC`, `XLS`, `IMG` …). */
+ fun typeLabel(name: String, mime: String): String {
+ val ext = name.substringAfterLast('.', "").lowercase()
+ return when {
+ mime == "application/pdf" || ext == "pdf" -> "PDF"
+ ext in setOf("doc", "docx", "odt", "rtf") -> "DOC"
+ ext in setOf("xls", "xlsx", "ods", "csv") -> "XLS"
+ ext in setOf("ppt", "pptx", "odp") -> "PPT"
+ mime.startsWith("image/") -> "IMG"
+ ext in setOf("zip", "7z", "rar", "gz", "tar") -> "ZIP"
+ ext == "ics" || mime == "text/calendar" -> "ICS"
+ ext.length in 1..4 -> ext.uppercase()
+ else -> "FILE"
+ }
+ }
+
+ /** Above this a device file cannot go as a copy (the server refuses it, 413); a Drive file goes as a link. */
+ const val ATTACH_MAX_BYTES = 25L * 1024 * 1024
+
+ /** Whether a Drive file of [size] is offered as a link first ("zu groß zum Anhängen"). */
+ fun linkFirst(size: Long): Boolean = size > 20L * 1024 * 1024
+
+ /** A file name of a picked file, cleaned for the server. */
+ fun fileName(raw: String?): String {
+ val n = raw?.substringAfterLast('/')?.replace(Regex("[\\u0000-\\u001f\\\\:*?\"<>|]"), "_")?.trim()?.take(180)
+ return n?.takeIf { it.isNotEmpty() && it != "." && it != ".." } ?: "attachment"
+ }
+}
diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt b/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt
new file mode 100644
index 00000000..1f3a28eb
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt
@@ -0,0 +1,117 @@
+package com.gatecontrol.android.mail
+
+import android.content.Context
+import android.content.pm.PackageManager
+import java.net.URI
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.Base64
+
+/**
+ * "Mit Microsoft anmelden" for a mail account (contract D): authorization code with PKCE in the system browser
+ * (Custom Tab), answered to `msauth://com.gatecontrol.client/` — the hash is the URL-encoded Base64 SHA-1 of the
+ * app's signing certificate, as Microsoft registers Android apps. The server redeems the code (`POST
+ * accounts/microsoft`), the refresh token never reaches the phone. Pure except [signatureHash] of the running app.
+ */
+object MailMicrosoft {
+
+ const val SCHEME = "msauth"
+ /** The redirect's host: the app's package as published (contract D). */
+ const val HOST = "com.gatecontrol.client"
+ const val DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common"
+ val DEFAULT_SCOPES = listOf("openid", "profile", "email", "offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send")
+
+ private val random = SecureRandom()
+ private val VERIFIER = Regex("^[A-Za-z0-9._~-]{43,128}$")
+
+ private fun token(bytes: Int): String =
+ Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(bytes).also { random.nextBytes(it) })
+
+ /** PKCE `code_verifier`: 43…128 characters of the unreserved set (64 random bytes → 86). */
+ fun newVerifier(): String = token(64)
+
+ fun newState(): String = token(24)
+
+ fun isVerifier(v: String): Boolean = VERIFIER.matches(v)
+
+ /** `code_challenge` (S256): base64url(sha256(verifier)) without padding. */
+ fun challenge(verifier: String): String =
+ Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII)))
+
+ /** Base64 (standard, with padding) of the SHA-1 of a signing certificate. */
+ fun certHash(cert: ByteArray): String = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-1").digest(cert))
+
+ /** `msauth://com.gatecontrol.client/`. */
+ fun redirectUri(hash: String): String = "$SCHEME://$HOST/" + URLEncoder.encode(hash, "UTF-8")
+
+ /**
+ * The sign-in page: `/oauth2/v2.0/authorize` with the client id, the scopes (space separated), the
+ * redirect, state and challenge. [loginHint] fills in the address (re-login of an account). Null when the
+ * authority is not an https address.
+ */
+ fun authorizeUrl(
+ authority: String,
+ clientId: String,
+ scopes: List,
+ redirectUri: String,
+ state: String,
+ challenge: String,
+ loginHint: String? = null,
+ ): String? {
+ val base = authority.trimEnd('/')
+ val uri = runCatching { URI(base) }.getOrNull() ?: return null
+ if (uri.scheme != "https" || uri.host.isNullOrEmpty() || uri.rawQuery != null) return null
+ fun enc(v: String) = URLEncoder.encode(v, "UTF-8").replace("+", "%20")
+ return buildString {
+ append(base).append("/oauth2/v2.0/authorize")
+ append("?client_id=").append(enc(clientId))
+ append("&response_type=code")
+ append("&redirect_uri=").append(enc(redirectUri))
+ append("&response_mode=query")
+ append("&scope=").append(enc(scopes.joinToString(" ")))
+ append("&state=").append(enc(state))
+ append("&code_challenge=").append(enc(challenge))
+ append("&code_challenge_method=S256")
+ if (loginHint != null) append("&login_hint=").append(enc(loginHint)) else append("&prompt=select_account")
+ }
+ }
+
+ /** The browser's answer: a code, or an error with its description. */
+ data class Answer(val state: String?, val code: String?, val error: String?, val description: String?)
+
+ fun isRedirect(raw: String?): Boolean {
+ val uri = raw?.let { runCatching { URI(it) }.getOrNull() } ?: return false
+ return uri.scheme.equals(SCHEME, ignoreCase = true) && uri.host.equals(HOST, ignoreCase = true)
+ }
+
+ /** `msauth://com.gatecontrol.client/?code=…&state=…` (or `error=…`); null for anything else. */
+ fun parse(raw: String?): Answer? {
+ if (!isRedirect(raw)) return null
+ val uri = runCatching { URI(raw) }.getOrNull() ?: return null
+ val q = uri.rawQuery ?: uri.rawFragment ?: return null
+ val map = HashMap()
+ for (part in q.split('&')) {
+ if (part.isEmpty()) continue
+ val k = part.substringBefore('=')
+ val v = runCatching { URLDecoder.decode(part.substringAfter('=', ""), "UTF-8") }.getOrNull() ?: return null
+ if (k !in map) map[k] = v
+ }
+ val code = map["code"]?.takeIf { it.isNotEmpty() && it.length <= 4096 }
+ val error = map["error"]?.takeIf { it.isNotEmpty() }
+ if (code == null && error == null) return null
+ return Answer(map["state"], code, error, map["error_description"]?.take(300))
+ }
+
+ /**
+ * The running app's signature hash, null when Android does not tell: the current signing certificate (the last of
+ * a rotated key's history; the first signer of an app signed by several).
+ */
+ fun signatureHash(context: Context): String? = runCatching {
+ val info = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
+ val signing = info.signingInfo ?: return null
+ val cert = if (signing.hasMultipleSigners()) signing.apkContentsSigners?.firstOrNull() else signing.signingCertificateHistory?.lastOrNull()
+ cert?.toByteArray()?.let(::certHash)
+ }.getOrNull()
+}
diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt
new file mode 100644
index 00000000..69906c8b
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt
@@ -0,0 +1,337 @@
+package com.gatecontrol.android.mail
+
+import com.google.gson.JsonObject
+
+/** Plugin id of GateControl Mail (contract C/D). */
+const val MAIL_PLUGIN = "gatecontrol-mail"
+
+/** The answer of a Mail call: never an exception (except cancellation). */
+sealed class MResult {
+ data class Ok(val value: T) : MResult()
+ /** Refused or not understood: the plugin's `error.code` (or `http_`), the HTTP status, the error object. */
+ data class Failed(val code: String, val http: Int, val error: JsonObject? = null) : MResult()
+ data object Offline : MResult()
+ data object NotConfigured : MResult()
+
+ fun valueOrNull(): T? = (this as? Ok)?.value
+}
+
+internal fun MResult<*>.notOk(): MResult = when (this) {
+ is MResult.Ok -> MResult.Failed(MailApi.CODE_INVALID, 0)
+ is MResult.Failed -> this
+ MResult.Offline -> MResult.Offline
+ MResult.NotConfigured -> MResult.NotConfigured
+}
+
+/** `GET me`: what the server offers and the person's accounts. */
+data class MMe(
+ val features: Set,
+ val limits: MLimits,
+ val microsoft: MMicrosoft,
+ val accounts: List,
+ val unreadAll: Int,
+ val unreadByAccount: Map,
+ val settings: MSettings,
+) {
+ fun has(feature: String): Boolean = feature in features
+
+ fun account(id: Long?): MAccount? = id?.let { a -> accounts.firstOrNull { it.id == a } }
+
+ companion object {
+ const val FEATURE_MAIL = "mail"
+ const val FEATURE_AI = "ai"
+ const val FEATURE_MICROSOFT = "microsoft"
+ const val FEATURE_DRIVE = "drive"
+ const val FEATURE_FAMILY_CALENDAR = "familyCalendar"
+ const val FEATURE_IMAGES = "images"
+ }
+}
+
+data class MLimits(val accountsMax: Int, val quotaBytes: Long, val usedBytes: Long, val retentionMonths: Int)
+
+/** Microsoft sign-in (public client with PKCE): no client id → not set up yet. */
+data class MMicrosoft(val clientId: String?, val authority: String, val scopes: List)
+
+/** The person's list settings (`PUT settings`). */
+data class MSettings(val threads: Boolean, val swipeLeft: String, val swipeRight: String) {
+ companion object {
+ const val SWIPE_ARCHIVE = "archive"
+ const val SWIPE_DELETE = "delete"
+ const val SWIPE_READ = "read"
+ const val SWIPE_NONE = "none"
+ val SWIPES = listOf(SWIPE_ARCHIVE, SWIPE_DELETE, SWIPE_READ, SWIPE_NONE)
+ val DEFAULT = MSettings(threads = true, swipeLeft = SWIPE_ARCHIVE, swipeRight = SWIPE_READ)
+ }
+}
+
+data class MAccount(
+ val id: Long,
+ /** gmx | webde | tonline | gmail | outlook | m365 | imap */
+ val provider: String,
+ val email: String,
+ val name: String?,
+ /** `#RRGGBB`, null when the server sends none or a bad one. */
+ val color: String?,
+ /** personal | business */
+ val kind: String,
+ /** ok | syncing | auth_failed | unreachable */
+ val state: String,
+ val errorCode: String?,
+ val errorAt: Long?,
+ /** off | relevant | all */
+ val push: String,
+ val ai: Boolean,
+ /** off | suggest | auto */
+ val attachments: String,
+ val signature: String?,
+) {
+ val label: String get() = name?.takeIf { it.isNotBlank() } ?: email
+
+ val microsoft: Boolean get() = provider == PROVIDER_OUTLOOK || provider == PROVIDER_M365
+
+ companion object {
+ const val PROVIDER_GMX = "gmx"
+ const val PROVIDER_WEBDE = "webde"
+ const val PROVIDER_TONLINE = "tonline"
+ const val PROVIDER_GMAIL = "gmail"
+ const val PROVIDER_OUTLOOK = "outlook"
+ const val PROVIDER_M365 = "m365"
+ const val PROVIDER_IMAP = "imap"
+ const val STATE_OK = "ok"
+ const val STATE_SYNCING = "syncing"
+ const val STATE_AUTH_FAILED = "auth_failed"
+ const val STATE_UNREACHABLE = "unreachable"
+ const val KIND_PERSONAL = "personal"
+ const val KIND_BUSINESS = "business"
+ val PUSH = listOf("off", "relevant", "all")
+ val ATTACHMENTS = listOf("off", "suggest", "auto")
+ }
+}
+
+/** `GET providers`: one entry of "Konto hinzufügen". */
+data class MProvider(
+ val id: String,
+ val label: String,
+ /** password | app_password | microsoft | imap */
+ val auth: String,
+ val hintDe: String?,
+ val hintEn: String?,
+ val help: String?,
+) {
+ fun hint(lang: String): String? = if (lang == "en") hintEn ?: hintDe else hintDe ?: hintEn
+
+ companion object {
+ const val AUTH_PASSWORD = "password"
+ const val AUTH_APP_PASSWORD = "app_password"
+ const val AUTH_MICROSOFT = "microsoft"
+ const val AUTH_IMAP = "imap"
+ }
+}
+
+data class MServer(val host: String, val port: Int, val security: String)
+
+/** `POST accounts/discover`. */
+data class MDiscover(val provider: String?, val auth: String?, val imap: MServer?, val smtp: MServer?, val hintDe: String?, val hintEn: String?)
+
+data class MFolder(
+ val id: Long,
+ val accountId: Long,
+ val name: String,
+ val path: String,
+ /** inbox | sent | drafts | archive | junk | trash | null */
+ val role: String?,
+ val parentId: Long?,
+ val unread: Int,
+ val total: Int,
+) {
+ companion object {
+ const val ROLE_INBOX = "inbox"
+ const val ROLE_SENT = "sent"
+ const val ROLE_DRAFTS = "drafts"
+ const val ROLE_ARCHIVE = "archive"
+ const val ROLE_JUNK = "junk"
+ const val ROLE_TRASH = "trash"
+ }
+}
+
+data class MAddress(val name: String?, val email: String) {
+ val label: String get() = name?.takeIf { it.isNotBlank() } ?: email
+}
+
+/** A mail in a list (`Summary`). */
+data class MSummary(
+ val id: Long,
+ val accountId: Long,
+ val folderId: Long?,
+ val threadId: Long?,
+ val threadCount: Int,
+ val from: MAddress,
+ val to: List,
+ val subject: String,
+ val snippet: String,
+ val date: Long,
+ val unread: Boolean,
+ val flagged: Boolean,
+ val answered: Boolean,
+ val hasAttachments: Boolean,
+ /** relevant | other */
+ val tab: String,
+ val savedToDrive: Boolean,
+ val aiSummary: String?,
+) {
+ companion object {
+ const val TAB_RELEVANT = "relevant"
+ const val TAB_OTHER = "other"
+ const val TAB_ALL = "all"
+ }
+}
+
+data class MCounts(val relevant: Int, val other: Int)
+
+/** `GET messages`: a page of a list. */
+data class MPage(val items: List, val next: String?, val counts: MCounts?)
+
+/** `GET search`. */
+data class MSearchPage(val items: List, val next: String?, val remote: Boolean)
+
+data class MSavedTo(val nodeId: Long, val where: String)
+
+/** Where Drive would put an attachment. */
+data class MSuggestion(
+ val folderId: Long,
+ val areaId: String?,
+ val path: String?,
+ val where: String?,
+ val name: String?,
+ val confidence: Double,
+ /** rule | learned | ai | kind */
+ val reason: String,
+)
+
+data class MAttachment(
+ val id: Long,
+ val name: String,
+ val mime: String,
+ val size: Long,
+ val inline: Boolean,
+ val cid: String?,
+ val savedTo: MSavedTo?,
+ val suggestion: MSuggestion?,
+)
+
+data class MUnsubscribe(val url: String?, val mail: String?)
+
+/** `GET messages/`: a mail to read. */
+data class MMessage(
+ val summary: MSummary,
+ val cc: List,
+ val bcc: List,
+ val replyTo: List,
+ /** Cleaned by the server; external pictures as `img/`, embedded ones as `cid:`. */
+ val html: String?,
+ val text: String?,
+ val imagesBlocked: Boolean,
+ val trackers: Int,
+ val attachments: List,
+ val unsubscribe: MUnsubscribe?,
+ /** The body is not on the server (any more) and could not be fetched from the provider. */
+ val bodyMissing: Boolean = false,
+) {
+ val id: Long get() = summary.id
+}
+
+/** `GET/POST ai/`: "Kurz gesagt", "Zu tun", answer suggestions. */
+data class MAi(val done: Boolean, val summary: String?, val todos: List, val replies: List) {
+ companion object {
+ val NONE = MAi(done = false, summary = null, todos = emptyList(), replies = emptyList())
+ }
+}
+
+data class MTodo(
+ /** question | deadline | event | amount | task */
+ val kind: String,
+ val text: String,
+ /** `YYYY-MM-DD` */
+ val date: String?,
+ /** `HH:mm` */
+ val time: String?,
+ val amount: Double?,
+ val currency: String?,
+) {
+ companion object {
+ const val QUESTION = "question"
+ const val DEADLINE = "deadline"
+ const val EVENT = "event"
+ const val AMOUNT = "amount"
+ const val TASK = "task"
+ }
+}
+
+data class MReply(val label: String, val text: String)
+
+/** One attachment of a draft as the server lists it. */
+data class MDraftAttachment(val id: Long, val name: String, val size: Long, val mime: String, val asLink: Boolean, val url: String?)
+
+data class MWarning(val kind: String, val text: String)
+
+data class MDraft(
+ val id: Long,
+ val accountId: Long,
+ val to: List,
+ val cc: List,
+ val bcc: List,
+ val subject: String,
+ val html: String,
+ val attachments: List,
+ val warnings: List,
+)
+
+/** An attachment to send with a draft (`Att`). */
+sealed class MAtt {
+ /** Already on the draft (kept when the draft is saved again). */
+ data class Kept(val id: Long) : MAtt()
+ /** Uploaded from the device (plugin media); the name rides along, the media itself has none ([mime] stays here). */
+ data class Upload(val mediaId: String, val name: String, val mime: String) : MAtt()
+ /** An attachment of the mail being forwarded. */
+ data class Forwarded(val messageId: Long, val attachmentId: Long) : MAtt()
+ /** A Drive file as a copy or as a link. */
+ data class Drive(val nodeId: Long, val asLink: Boolean, val expiresDays: Int? = null, val password: String? = null) : MAtt()
+}
+
+/** `POST drafts//send`. */
+data class MSend(val outboxId: Long, val undoUntil: Long?, val sendAt: Long?)
+
+data class MOutboxItem(
+ val id: Long,
+ val draftId: Long?,
+ val accountId: Long?,
+ val subject: String,
+ val sendAt: Long?,
+ /** waiting | sending | failed */
+ val state: String,
+ val error: String?,
+)
+
+data class MContact(val name: String?, val email: String, val source: String)
+
+/** "Zum Ablegen": an attachment the AI was not sure about. */
+data class MFilingItem(
+ val id: Long,
+ val messageId: Long,
+ val attachmentId: Long,
+ val name: String,
+ val from: MAddress?,
+ val date: Long?,
+ val suggestion: MSuggestion?,
+)
+
+/** `GET messages//attachments/`: where its file lies. */
+data class MAttachmentMedia(val mediaId: String, val name: String, val mime: String, val size: Long)
+
+data class MSaveAll(val saved: List>, val failed: List>)
+
+/** `POST messages//attachments//drive/suggest`. */
+data class MSuggest(val suggestion: MSuggestion?, val duplicate: Boolean)
+
+/** Realtime event `mail` `{ kind: 'changed', accountId, folderId }`. */
+data class MChanged(val accountId: Long?, val folderId: Long?)
diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt
new file mode 100644
index 00000000..ec754f5b
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt
@@ -0,0 +1,42 @@
+package com.gatecontrol.android.mail
+
+import com.gatecontrol.android.notify.model.PushMessage
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+
+/**
+ * Where a tap on a Mail push leads (pure, unit-tested). Topic `mail` (title = sender, text = subject) carries
+ * `data.mail = { messageId, accountId }` and opens the mail; `mail_failed` carries `{ outboxId }` and opens the
+ * outbox. Their channel is called "Mail" whatever the server names it.
+ */
+object MailPush {
+ private const val TOPIC_PREFIX = "plugin:$MAIL_PLUGIN:"
+ const val TOPIC_MAIL = TOPIC_PREFIX + "mail"
+ const val TOPIC_FAILED = TOPIC_PREFIX + "mail_failed"
+
+ fun isMailMessage(message: PushMessage): Boolean = message.topic.startsWith(TOPIC_PREFIX)
+
+ /** Whether a notification channel (by its topic) is Mail's own: shown as "Mail". */
+ fun isMailTopic(topic: String): Boolean = topic == TOPIC_MAIL
+
+ /** `message:` | `outbox:` | `outbox`; null for other messages. */
+ fun destination(message: PushMessage): String? {
+ if (!isMailMessage(message)) return null
+ val mail = message.dataJson?.let { raw -> runCatching { JsonParser.parseString(raw).asJsonObject.get("mail") }.getOrNull() }
+ ?.takeIf { it.isJsonObject }?.asJsonObject
+ return when (message.topic) {
+ TOPIC_MAIL -> mail?.id("messageId")?.let { "message:$it" }
+ TOPIC_FAILED -> mail?.id("outboxId")?.let { "outbox:$it" } ?: "outbox"
+ else -> null
+ }
+ }
+
+ private fun JsonObject.id(key: String): Long? =
+ get(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString.trim().toLong() }.getOrNull() }?.takeIf { it > 0 }
+
+ /** A destination of [destination] → the mail's id, or null. */
+ fun messageOf(dest: String): Long? = dest.takeIf { it.startsWith("message:") }?.removePrefix("message:")?.toLongOrNull()?.takeIf { it > 0 }
+
+ /** Whether a destination opens the outbox. */
+ fun isOutbox(dest: String): Boolean = dest == "outbox" || dest.startsWith("outbox:")
+}
diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt
new file mode 100644
index 00000000..384d9b35
--- /dev/null
+++ b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt
@@ -0,0 +1,213 @@
+package com.gatecontrol.android.mail
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.SystemClock
+import com.gatecontrol.android.MailSigninActivity
+import com.gatecontrol.android.data.SetupRepository
+import com.gatecontrol.android.familie.PluginDirectory
+import com.gatecontrol.android.support.CrashReports
+import com.google.gson.JsonElement
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** "Gesendet · Rückgängig" after `send`: until [untilElapsed] (elapsed realtime) the send can be stopped. */
+data class MUndo(val outboxId: Long, val draftId: Long?, val untilElapsed: Long, val sendAt: Long?, val planned: Boolean)
+
+/**
+ * What the Mail screens share: the last `me` (accounts, features, unread) and the folders, the plugin's realtime
+ * events (`mail { kind: 'changed' }` → the lists load again) and the send waiting to be undone.
+ */
+@Singleton
+class MailRepository @Inject constructor(
+ val api: MailApi,
+ val cache: MailCache,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler)
+
+ private val _me = MutableStateFlow(null)
+ val me: StateFlow = _me.asStateFlow()
+
+ private val _folders = MutableStateFlow