From 2cf5977dd498b44de956355485e8c8b037f0f0c5 Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Fri, 10 Jul 2026 04:43:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D1=8B=20=E2=80=94=20=D0=BD=D0=B0=20=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20API,=20?= =?UTF-8?q?=D0=9E=D0=91=D0=A9=D0=98=D0=95=20=D1=81=20=D0=B2=D0=B5=D0=B1?= =?UTF-8?q?=D0=BE=D0=BC,=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B5=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменил собственный sieve-слой (маркированные блоки F7MOBILE) на штатный API NC Mail GET/PUT /apps/mail/api/filter/{accountId} — тот самый, которым пользуется веб-модалка «Новый фильтр». Теперь: - видны ВСЕ правила (созданные и в вебе, и в приложении); - любое правило можно включить/выключить и удалить с телефона; - простые правила (1 условие) редактируются инлайн с префиллом формы; сложные помечаются «правится в вебе», их JSON сохраняется целиком (MailFilterRule держит raw и не теряет незнакомые поля); - сериализацию в sieve делает сервер (FilterBuilder, секция «### Nextcloud Mail: Filters ###») — совместимость гарантирована. Действия конструктора: переместить в папку / пометить прочитанным (addsystemflag \\Seen). MailFilterRuleTest — 3 юнит-теста зелёные. Co-Authored-By: Claude Fable 5 --- .../f7cloud/feature/mail/MailRepository.kt | 163 +++++++++--------- .../f7cloud/feature/mail/MailScreen.kt | 6 +- .../feature/mail/MailSieveFiltersSheet.kt | 139 ++++++++++----- .../f7cloud/feature/mail/MailViewModel.kt | 52 ++++-- .../feature/mail/MailFilterRuleTest.kt | 52 ++++++ .../feature/mail/MailSieveScriptTest.kt | 62 ------- 6 files changed, 270 insertions(+), 204 deletions(-) create mode 100644 feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailFilterRuleTest.kt delete mode 100644 feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailSieveScriptTest.kt diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailRepository.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailRepository.kt index bb9f664..be91de5 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailRepository.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailRepository.kt @@ -967,29 +967,34 @@ class MailRepository { }.sortedByDescending { it.updatedAt } } - // --- Sieve-фильтры (паритет живого веба, модалка «Новый фильтр») --- + // --- Фильтры почты: официальный API NC Mail (тот же, что у веб-модалки «Новый фильтр») --- + // GET/PUT /apps/mail/api/filter/{accountId}; сервер сам сериализует в sieve-скрипт + // (секция «### Nextcloud Mail: Filters ###»), правила ОБЩИЕ между вебом и приложением. - fun getSieveScript(session: AuthSession, accountId: Int): String { + fun getMailFilters(session: AuthSession, accountId: Int): List { val client = authedClient(session) - val json = getJson(client, "${apiBase(session)}/sieve/active/$accountId") - return (json as? JSONObject)?.optString("script").orEmpty() + val array = getJsonArray(client, "${apiBase(session)}/filter/$accountId") + return buildList { + for (i in 0 until array.length()) { + array.optJSONObject(i)?.let { add(MailFilterRule(it)) } + } + } } - fun putSieveScript(session: AuthSession, accountId: Int, script: String) { + fun putMailFilters(session: AuthSession, accountId: Int, filters: List) { val client = authedClient(session) - val payload = JSONObject().put("script", script).toString() + val payload = JSONObject() + .put("filters", JSONArray().apply { filters.forEach { put(it.raw) } }) + .toString() val request = Request.Builder() - .url("${apiBase(session)}/sieve/active/$accountId") + .url("${apiBase(session)}/filter/$accountId") .header("OCS-APIRequest", "true") .header("Accept", "application/json") .put(payload.toRequestBody("application/json".toMediaType())) .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) { - val message = response.body?.string() - ?.let { runCatching { JSONObject(it).optString("message") }.getOrNull() } - .orEmpty() - error("Sieve HTTP ${response.code}${if (message.isNotBlank()) ": $message" else ""}") + error("Filter API HTTP ${response.code}") } } } @@ -999,76 +1004,78 @@ class MailRepository { } } -/** Правило фильтра, которым управляет приложение (наш маркированный блок в sieve-скрипте). */ -data class MailSieveRule( - val id: String, - val name: String, - /** from | subject | to */ - val field: String, - val contains: String, - /** fileinto | markread | discard */ - val action: String, - val targetFolder: String = "", -) - /** - * Наши правила живут в блоках `# F7MOBILE-BEGIN {json}` … `# F7MOBILE-END`. - * Остальной текст скрипта (правила из веба/пользовательские) не трогаем. + * Правило фильтра почты. Держим исходный JSON (raw) целиком, чтобы при правке + * простых полей не терять незнакомые поля сложных правил, созданных в вебе. */ -object MailSieveScript { - private const val REQUIRE_MARK = "# F7MOBILE-REQUIRE" - private const val BEGIN = "# F7MOBILE-BEGIN " - private const val END = "# F7MOBILE-END" - private val blockPattern = Regex( - "^${Regex.escape(BEGIN)}(\\{.*?\\})\\n.*?^${Regex.escape(END)}\\n?", - setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL), - ) +class MailFilterRule(val raw: JSONObject) { + val name: String get() = raw.optString("name") + val enable: Boolean get() = raw.optBoolean("enable", true) + val operator: String get() = raw.optString("operator", "allof") + val priority: Int get() = raw.optInt("priority", 10) - fun parseRules(script: String): List = - blockPattern.findAll(script).mapNotNull { match -> - runCatching { - val o = JSONObject(match.groupValues[1]) - MailSieveRule( - id = o.getString("id"), - name = o.optString("name"), - field = o.optString("field", "subject"), - contains = o.optString("contains"), - action = o.optString("action", "fileinto"), - targetFolder = o.optString("target"), - ) - }.getOrNull() - }.toList() - - fun renderScript(existing: String, rules: List): String { - // Снимаем наши старые блоки и require-заголовок; пользовательский текст сохраняем. - val withoutBlocks = blockPattern.replace(existing, "") - val user = withoutBlocks.lineSequence() - .filterNot { it.trim() == REQUIRE_MARK } - .filterNot { it.trim() == "require [\"fileinto\", \"imap4flags\"];" } - .joinToString("\n") - .trim('\n') - if (rules.isEmpty()) return if (user.isBlank()) "" else user + "\n" - val header = "$REQUIRE_MARK\nrequire [\"fileinto\", \"imap4flags\"];\n" - val blocks = rules.joinToString("\n") { rule -> - val meta = JSONObject() - .put("id", rule.id) - .put("name", rule.name) - .put("field", rule.field) - .put("contains", rule.contains) - .put("action", rule.action) - .put("target", rule.targetFolder) - val condition = "header :contains \"${sieveEscape(rule.field)}\" \"${sieveEscape(rule.contains)}\"" - val body = when (rule.action) { - "markread" -> " addflag \"\\\\Seen\";" - "discard" -> " discard;" - else -> " fileinto \"${sieveEscape(rule.targetFolder)}\";" + val tests: List>> + get() = raw.optJSONArray("tests")?.let { arr -> + buildList { + for (i in 0 until arr.length()) { + val t = arr.optJSONObject(i) ?: continue + val values = t.optJSONArray("values")?.let { v -> + (0 until v.length()).map { v.optString(it) } + }.orEmpty() + add(Triple(t.optString("field"), t.optString("operator", "contains"), values)) + } } - "$BEGIN$meta\nif $condition {\n$body\n}\n$END" - } - // require обязан быть в начале скрипта — наш заголовок идёт первым. - return listOf(header + blocks, user).filter { it.isNotBlank() }.joinToString("\n\n") + "\n" - } + }.orEmpty() - private fun sieveEscape(value: String): String = - value.replace("\\", "\\\\").replace("\"", "\\\"") + val actions: List + get() = raw.optJSONArray("actions")?.let { arr -> + (0 until arr.length()).mapNotNull { arr.optJSONObject(it) } + }.orEmpty() + + /** Простое правило (1 тест, действия из нашего конструктора) — редактируемо инлайн. */ + val isSimple: Boolean + get() = tests.size == 1 && actions.all { + it.optString("type") in setOf("fileinto", "addflag", "addsystemflag") + } + + fun withEnable(value: Boolean): MailFilterRule = + MailFilterRule(JSONObject(raw.toString()).put("enable", value)) + + companion object { + fun simple( + name: String, + field: String, + contains: String, + action: String, + targetFolder: String, + priority: Int = 10, + ): MailFilterRule { + val actions = JSONArray() + when (action) { + "markread" -> actions.put( + JSONObject().put("type", "addsystemflag").put("flag", "\\Seen"), + ) + else -> actions.put( + JSONObject().put("type", "fileinto").put("mailbox", targetFolder), + ) + } + return MailFilterRule( + JSONObject() + .put("name", name) + .put("enable", true) + .put("operator", "allof") + .put("priority", priority) + .put( + "tests", + JSONArray().put( + JSONObject() + .put("field", field) + .put("operator", "contains") + .put("values", JSONArray().put(contains)), + ), + ) + .put("actions", actions), + ) + } + } } diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt index 43b244a..8af21ba 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt @@ -262,8 +262,10 @@ fun MailScreen( saving = state.sieveSaving, error = state.sieveError, onDismiss = { vm.closeSieveFilters() }, - onAdd = { rule -> vm.addSieveRule(session, rule) }, - onDelete = { id -> vm.deleteSieveRule(session, id) }, + onAdd = { rule -> vm.addFilterRule(session, rule) }, + onUpdate = { index, rule -> vm.updateFilterRule(session, index, rule) }, + onDelete = { index -> vm.deleteFilterRule(session, index) }, + onToggle = { index, enable -> vm.toggleFilterRule(session, index, enable) }, ) BackHandler(enabled = mailCanGoBack) { diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSieveFiltersSheet.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSieveFiltersSheet.kt index e533558..de63a18 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSieveFiltersSheet.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSieveFiltersSheet.kt @@ -19,9 +19,12 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -40,53 +43,65 @@ import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7OutlinedField import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton -import java.util.UUID private val FIELD_OPTIONS = listOf("from" to "От", "subject" to "Тема", "to" to "Кому") private val ACTION_OPTIONS = listOf( "fileinto" to "Переместить в папку", "markread" to "Пометить прочитанным", - "discard" to "Удалить", ) -/** Пилюля-переключатель варианта (как чипы живой темы: 25dp, актив — #ECF9DE). */ +/** Пилюля-переключатель варианта (чипы живой темы: актив — #ECF9DE). */ @Composable private fun OptionChip(text: String, selected: Boolean, onClick: () -> Unit) { Box( modifier = Modifier .heightIn(min = 25.dp) .clip(RoundedCornerShape(100.dp)) - .background(if (selected) F7Colors.PrimaryLight else F7Colors.Background) - .then( - Modifier.padding(0.dp), - ) + .background(if (selected) F7Colors.PrimaryLight else F7Colors.SurfaceMuted) .clickable(onClick = onClick) .padding(horizontal = 10.dp, vertical = 4.dp), contentAlignment = Alignment.Center, ) { - Text( - text, - style = MaterialTheme.typography.bodyMedium, - color = F7Colors.TextPrimary, - ) + Text(text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary) } } +private fun ruleSummary(rule: MailFilterRule): String { + val tests = rule.tests.joinToString("; ") { (field, op, values) -> + val fieldLabel = FIELD_OPTIONS.firstOrNull { it.first == field }?.second ?: field + "$fieldLabel $op «${values.joinToString(", ")}»" + } + val actions = rule.actions.joinToString("; ") { action -> + when (action.optString("type")) { + "fileinto" -> "в папку «${action.optString("mailbox")}»" + "addflag", "addsystemflag" -> + if (action.optString("flag").contains("Seen")) "пометить прочитанным" + else "флаг «${action.optString("flag")}»" + "keep" -> "оставить" + "stop" -> "стоп" + else -> action.optString("type") + } + } + return "$tests → $actions" +} + /** - * Модалка «Фильтры» (sieve) — паритет живого веба: список правил приложения - * + конструктор «Новый фильтр» (поле содержит значение → действие). + * Фильтры почты — общие с веб-версией (официальный API NC Mail). + * Список всех правил (вкл/выкл, удаление), редактирование простых, конструктор нового. */ @Composable fun MailSieveFiltersSheet( visible: Boolean, - rules: List, + rules: List, folders: List, loading: Boolean, saving: Boolean, error: String?, onDismiss: () -> Unit, - onAdd: (MailSieveRule) -> Unit, - onDelete: (String) -> Unit, + onAdd: (MailFilterRule) -> Unit, + onUpdate: (Int, MailFilterRule) -> Unit, + onDelete: (Int) -> Unit, + onToggle: (Int, Boolean) -> Unit, ) { if (!visible) return var name by rememberSaveable { mutableStateOf("") } @@ -94,6 +109,27 @@ fun MailSieveFiltersSheet( var contains by rememberSaveable { mutableStateOf("") } var action by rememberSaveable { mutableStateOf("fileinto") } var targetFolder by rememberSaveable(folders) { mutableStateOf(folders.firstOrNull().orEmpty()) } + // -1 = создание нового; иначе — индекс редактируемого правила + var editingIndex by rememberSaveable { mutableIntStateOf(-1) } + + fun loadIntoForm(rule: MailFilterRule) { + name = rule.name + rule.tests.firstOrNull()?.let { (f, _, values) -> + field = f + contains = values.joinToString(", ") + } + val act = rule.actions.firstOrNull() + action = if (act?.optString("type") == "fileinto") "fileinto" else "markread" + if (act?.optString("type") == "fileinto") { + targetFolder = act.optString("mailbox") + } + } + + fun clearForm() { + name = "" + contains = "" + editingIndex = -1 + } Dialog( onDismissRequest = onDismiss, @@ -102,7 +138,7 @@ fun MailSieveFiltersSheet( Surface( modifier = Modifier .fillMaxWidth(0.94f) - .heightIn(max = 640.dp), + .heightIn(max = 660.dp), shape = RoundedCornerShape(16.dp), color = Color.White, ) { @@ -128,19 +164,25 @@ fun MailSieveFiltersSheet( else -> { if (rules.isEmpty()) { Text( - "Фильтров из приложения пока нет. Правила, созданные в веб-версии, не показываются и не изменяются.", + "Фильтров пока нет.", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary, ) } - rules.forEach { rule -> + rules.forEachIndexed { index, rule -> Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 36.dp) .clip(RoundedCornerShape(10.dp)) - .background(F7Colors.SurfaceMuted) - .padding(horizontal = 10.dp, vertical = 8.dp), + .background( + if (editingIndex == index) F7Colors.PrimaryLight else F7Colors.SurfaceMuted, + ) + .clickable(enabled = !saving && rule.isSimple) { + editingIndex = index + loadIntoForm(rule) + } + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -153,32 +195,36 @@ fun MailSieveFiltersSheet( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - val fieldLabel = FIELD_OPTIONS.firstOrNull { it.first == rule.field }?.second ?: rule.field - val actionLabel = when (rule.action) { - "markread" -> "пометить прочитанным" - "discard" -> "удалить" - else -> "в папку «${rule.targetFolder}»" - } Text( - "$fieldLabel содержит «${rule.contains}» → $actionLabel", + ruleSummary(rule) + + if (!rule.isSimple) " (сложное — правится в вебе)" else "", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } + Switch( + checked = rule.enable, + onCheckedChange = { onToggle(index, it) }, + enabled = !saving, + colors = SwitchDefaults.colors(checkedTrackColor = F7Colors.Primary), + ) Icon( imageVector = Icons.Filled.Close, contentDescription = "Удалить фильтр", tint = F7Colors.TextSecondary, modifier = Modifier .size(16.dp) - .clickable(enabled = !saving) { onDelete(rule.id) }, + .clickable(enabled = !saving) { + if (editingIndex == index) clearForm() + onDelete(index) + }, ) } } Text( - "Новый фильтр", + if (editingIndex >= 0) "Изменить фильтр" else "Новый фильтр", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = F7Colors.TextPrimary, @@ -225,22 +271,27 @@ fun MailSieveFiltersSheet( Text(error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.weight(1f)) + F7SecondaryButton( + if (editingIndex >= 0) "Отменить правку" else "Закрыть", + onClick = { if (editingIndex >= 0) clearForm() else onDismiss() }, + modifier = Modifier.weight(1f), + ) F7PrimaryButton( - text = if (saving) "Сохранение…" else "Создать", + text = when { + saving -> "Сохранение…" + editingIndex >= 0 -> "Сохранить" + else -> "Создать" + }, onClick = { - onAdd( - MailSieveRule( - id = UUID.randomUUID().toString().take(8), - name = name.trim().ifBlank { "Фильтр" }, - field = field, - contains = contains.trim(), - action = action, - targetFolder = if (action == "fileinto") targetFolder else "", - ), + val rule = MailFilterRule.simple( + name = name.trim().ifBlank { "Фильтр" }, + field = field, + contains = contains.trim(), + action = action, + targetFolder = if (action == "fileinto") targetFolder else "", ) - name = "" - contains = "" + if (editingIndex >= 0) onUpdate(editingIndex, rule) else onAdd(rule) + clearForm() }, enabled = !saving && contains.isNotBlank() && (action != "fileinto" || targetFolder.isNotBlank()), diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt index dca4dc1..4cdde4c 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt @@ -50,9 +50,9 @@ data class MailUiState( val createEventSheetOpen: Boolean = false, val createTargets: List = emptyList(), val creatingFromMessage: Boolean = false, - // Sieve-фильтры (паритет живого веба) + // Фильтры почты (официальный API NC Mail — общие с вебом) val sieveAccountId: Int? = null, - val sieveRules: List = emptyList(), + val sieveRules: List = emptyList(), val sieveLoading: Boolean = false, val sieveSaving: Boolean = false, val sieveError: String? = null, @@ -1105,7 +1105,7 @@ class MailViewModel( } } - // --- Sieve-фильтры --- + // --- Фильтры почты (общие с вебом, API /api/filter/{accountId}) --- fun openSieveFilters(session: AuthSession, accountId: Int) { _state.update { @@ -1113,12 +1113,12 @@ class MailViewModel( } viewModelScope.launch(Dispatchers.IO) { runCatching { - MailSieveScript.parseRules(repository.getSieveScript(session, accountId)) + repository.getMailFilters(session, accountId) }.onSuccess { rules -> _state.update { it.copy(sieveRules = rules, sieveLoading = false) } }.onFailure { t -> _state.update { - it.copy(sieveLoading = false, sieveError = t.message ?: "Sieve недоступен") + it.copy(sieveLoading = false, sieveError = t.message ?: "Фильтры недоступны") } } } @@ -1128,29 +1128,45 @@ class MailViewModel( _state.update { it.copy(sieveAccountId = null, sieveError = null) } } - fun addSieveRule(session: AuthSession, rule: MailSieveRule) { - saveSieveRules(session) { it + rule } + fun addFilterRule(session: AuthSession, rule: MailFilterRule) { + saveFilterRules(session) { it + rule } } - fun deleteSieveRule(session: AuthSession, ruleId: String) { - saveSieveRules(session) { rules -> rules.filterNot { it.id == ruleId } } + fun updateFilterRule(session: AuthSession, index: Int, rule: MailFilterRule) { + saveFilterRules(session) { rules -> + rules.toMutableList().also { if (index in it.indices) it[index] = rule } + } } - private fun saveSieveRules( + fun deleteFilterRule(session: AuthSession, index: Int) { + saveFilterRules(session) { rules -> + rules.toMutableList().also { if (index in it.indices) it.removeAt(index) } + } + } + + fun toggleFilterRule(session: AuthSession, index: Int, enable: Boolean) { + saveFilterRules(session) { rules -> + rules.toMutableList().also { + if (index in it.indices) it[index] = it[index].withEnable(enable) + } + } + } + + private fun saveFilterRules( session: AuthSession, - transform: (List) -> List, + transform: (List) -> List, ) { val accountId = _state.value.sieveAccountId ?: return + // Правки применяем к ТЕКУЩЕМУ локальному списку (он же только что с сервера); + // сервер хранит фильтры единым массивом — PUT перезаписывает секцию приложения Mail. + val rules = transform(_state.value.sieveRules) _state.update { it.copy(sieveSaving = true, sieveError = null) } viewModelScope.launch(Dispatchers.IO) { runCatching { - // Перечитываем скрипт перед записью — не затираем изменения из веба. - val current = repository.getSieveScript(session, accountId) - val rules = transform(MailSieveScript.parseRules(current)) - repository.putSieveScript(session, accountId, MailSieveScript.renderScript(current, rules)) - rules - }.onSuccess { rules -> - _state.update { it.copy(sieveRules = rules, sieveSaving = false) } + repository.putMailFilters(session, accountId, rules) + repository.getMailFilters(session, accountId) + }.onSuccess { fresh -> + _state.update { it.copy(sieveRules = fresh, sieveSaving = false) } }.onFailure { t -> _state.update { it.copy(sieveSaving = false, sieveError = t.message ?: "Ошибка сохранения") } } diff --git a/feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailFilterRuleTest.kt b/feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailFilterRuleTest.kt new file mode 100644 index 0000000..04c4654 --- /dev/null +++ b/feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailFilterRuleTest.kt @@ -0,0 +1,52 @@ +package ru.forbion.f7cloud.feature.mail + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MailFilterRuleTest { + + @Test + fun simple_rule_builds_web_compatible_json() { + val rule = MailFilterRule.simple( + name = "Работа", + field = "subject", + contains = "Отчёт", + action = "fileinto", + targetFolder = "Work", + ) + assertEquals("Работа", rule.name) + assertTrue(rule.enable) + assertEquals("allof", rule.operator) + assertEquals(listOf(Triple("subject", "contains", listOf("Отчёт"))), rule.tests) + assertEquals("fileinto", rule.actions.single().optString("type")) + assertEquals("Work", rule.actions.single().optString("mailbox")) + assertTrue(rule.isSimple) + } + + @Test + fun markread_uses_system_flag() { + val rule = MailFilterRule.simple("r", "from", "boss", "markread", "") + val action = rule.actions.single() + assertEquals("addsystemflag", action.optString("type")) + assertTrue(action.optString("flag").contains("Seen")) + } + + @Test + fun withEnable_preserves_unknown_fields() { + val raw = JSONObject( + """{"name":"web","enable":true,"operator":"anyof","priority":20, + "tests":[{"field":"subject","operator":"is","values":["x"]}, + {"field":"from","operator":"contains","values":["y"]}], + "actions":[{"type":"stop"}],"customField":"keepme"}""", + ) + val rule = MailFilterRule(raw) + assertFalse(rule.isSimple) + val disabled = rule.withEnable(false) + assertFalse(disabled.enable) + assertEquals("keepme", disabled.raw.optString("customField")) + assertEquals(2, disabled.tests.size) + } +} diff --git a/feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailSieveScriptTest.kt b/feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailSieveScriptTest.kt deleted file mode 100644 index bac88d9..0000000 --- a/feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailSieveScriptTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package ru.forbion.f7cloud.feature.mail - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class MailSieveScriptTest { - - private val rule = MailSieveRule( - id = "r1", - name = "Работа в папку", - field = "subject", - contains = "Отчёт", - action = "fileinto", - targetFolder = "Work", - ) - - @Test - fun render_and_parse_roundtrip() { - val script = MailSieveScript.renderScript("", listOf(rule)) - assertTrue(script.startsWith("# F7MOBILE-REQUIRE")) - assertTrue(script.contains("require [\"fileinto\", \"imap4flags\"];")) - assertTrue(script.contains("fileinto \"Work\";")) - val parsed = MailSieveScript.parseRules(script) - assertEquals(listOf(rule), parsed) - } - - @Test - fun user_script_is_preserved() { - val user = "# личное правило\nif header :contains \"from\" \"boss\" { keep; }" - val script = MailSieveScript.renderScript(user, listOf(rule)) - assertTrue(script.contains("личное правило")) - // удаляем наше правило — пользовательский текст остаётся - val cleaned = MailSieveScript.renderScript(script, emptyList()) - assertTrue(cleaned.contains("личное правило")) - assertTrue(!cleaned.contains("F7MOBILE")) - } - - @Test - fun quotes_are_escaped() { - val tricky = rule.copy(contains = "он сказал \"привет\"", targetFolder = "A\\B") - val script = MailSieveScript.renderScript("", listOf(tricky)) - assertTrue(script.contains("\\\"привет\\\"")) - val parsed = MailSieveScript.parseRules(script) - assertEquals(tricky.contains, parsed.single().contains) - assertEquals(tricky.targetFolder, parsed.single().targetFolder) - } - - @Test - fun markread_and_discard_actions() { - val script = MailSieveScript.renderScript( - "", - listOf( - rule.copy(id = "a", action = "markread", targetFolder = ""), - rule.copy(id = "b", action = "discard", targetFolder = ""), - ), - ) - assertTrue(script.contains("addflag \"\\\\Seen\";")) - assertTrue(script.contains("discard;")) - assertEquals(2, MailSieveScript.parseRules(script).size) - } -}