feat(mail): фильтры — на официальный API, ОБЩИЕ с вебом, полное управление

Заменил собственный 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 <noreply@anthropic.com>
This commit is contained in:
b-dev-mobile
2026-07-10 04:43:42 +00:00
parent ab864aa7d7
commit 2cf5977dd4
6 changed files with 270 additions and 204 deletions
@@ -967,29 +967,34 @@ class MailRepository {
}.sortedByDescending { it.updatedAt } }.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<MailFilterRule> {
val client = authedClient(session) val client = authedClient(session)
val json = getJson(client, "${apiBase(session)}/sieve/active/$accountId") val array = getJsonArray(client, "${apiBase(session)}/filter/$accountId")
return (json as? JSONObject)?.optString("script").orEmpty() 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<MailFilterRule>) {
val client = authedClient(session) 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() val request = Request.Builder()
.url("${apiBase(session)}/sieve/active/$accountId") .url("${apiBase(session)}/filter/$accountId")
.header("OCS-APIRequest", "true") .header("OCS-APIRequest", "true")
.header("Accept", "application/json") .header("Accept", "application/json")
.put(payload.toRequestBody("application/json".toMediaType())) .put(payload.toRequestBody("application/json".toMediaType()))
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
val message = response.body?.string() error("Filter API HTTP ${response.code}")
?.let { runCatching { JSONObject(it).optString("message") }.getOrNull() }
.orEmpty()
error("Sieve HTTP ${response.code}${if (message.isNotBlank()) ": $message" else ""}")
} }
} }
} }
@@ -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 { class MailFilterRule(val raw: JSONObject) {
private const val REQUIRE_MARK = "# F7MOBILE-REQUIRE" val name: String get() = raw.optString("name")
private const val BEGIN = "# F7MOBILE-BEGIN " val enable: Boolean get() = raw.optBoolean("enable", true)
private const val END = "# F7MOBILE-END" val operator: String get() = raw.optString("operator", "allof")
private val blockPattern = Regex( val priority: Int get() = raw.optInt("priority", 10)
"^${Regex.escape(BEGIN)}(\\{.*?\\})\\n.*?^${Regex.escape(END)}\\n?",
setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL), val tests: List<Triple<String, String, List<String>>>
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))
}
}
}.orEmpty()
val actions: List<JSONObject>
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(
fun parseRules(script: String): List<MailSieveRule> = JSONObject().put("type", "fileinto").put("mailbox", targetFolder),
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<MailSieveRule>): 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)}\";"
} }
"$BEGIN$meta\nif $condition {\n$body\n}\n$END" 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),
)
} }
// require обязан быть в начале скрипта — наш заголовок идёт первым.
return listOf(header + blocks, user).filter { it.isNotBlank() }.joinToString("\n\n") + "\n"
} }
private fun sieveEscape(value: String): String =
value.replace("\\", "\\\\").replace("\"", "\\\"")
} }
@@ -262,8 +262,10 @@ fun MailScreen(
saving = state.sieveSaving, saving = state.sieveSaving,
error = state.sieveError, error = state.sieveError,
onDismiss = { vm.closeSieveFilters() }, onDismiss = { vm.closeSieveFilters() },
onAdd = { rule -> vm.addSieveRule(session, rule) }, onAdd = { rule -> vm.addFilterRule(session, rule) },
onDelete = { id -> vm.deleteSieveRule(session, id) }, 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) { BackHandler(enabled = mailCanGoBack) {
@@ -19,9 +19,12 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable 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.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import java.util.UUID
private val FIELD_OPTIONS = listOf("from" to "От", "subject" to "Тема", "to" to "Кому") private val FIELD_OPTIONS = listOf("from" to "От", "subject" to "Тема", "to" to "Кому")
private val ACTION_OPTIONS = listOf( private val ACTION_OPTIONS = listOf(
"fileinto" to "Переместить в папку", "fileinto" to "Переместить в папку",
"markread" to "Пометить прочитанным", "markread" to "Пометить прочитанным",
"discard" to "Удалить",
) )
/** Пилюля-переключатель варианта (как чипы живой темы: 25dp, актив — #ECF9DE). */ /** Пилюля-переключатель варианта (чипы живой темы: актив — #ECF9DE). */
@Composable @Composable
private fun OptionChip(text: String, selected: Boolean, onClick: () -> Unit) { private fun OptionChip(text: String, selected: Boolean, onClick: () -> Unit) {
Box( Box(
modifier = Modifier modifier = Modifier
.heightIn(min = 25.dp) .heightIn(min = 25.dp)
.clip(RoundedCornerShape(100.dp)) .clip(RoundedCornerShape(100.dp))
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Background) .background(if (selected) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
.then(
Modifier.padding(0.dp),
)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 4.dp), .padding(horizontal = 10.dp, vertical = 4.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
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 @Composable
fun MailSieveFiltersSheet( fun MailSieveFiltersSheet(
visible: Boolean, visible: Boolean,
rules: List<MailSieveRule>, rules: List<MailFilterRule>,
folders: List<String>, folders: List<String>,
loading: Boolean, loading: Boolean,
saving: Boolean, saving: Boolean,
error: String?, error: String?,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAdd: (MailSieveRule) -> Unit, onAdd: (MailFilterRule) -> Unit,
onDelete: (String) -> Unit, onUpdate: (Int, MailFilterRule) -> Unit,
onDelete: (Int) -> Unit,
onToggle: (Int, Boolean) -> Unit,
) { ) {
if (!visible) return if (!visible) return
var name by rememberSaveable { mutableStateOf("") } var name by rememberSaveable { mutableStateOf("") }
@@ -94,6 +109,27 @@ fun MailSieveFiltersSheet(
var contains by rememberSaveable { mutableStateOf("") } var contains by rememberSaveable { mutableStateOf("") }
var action by rememberSaveable { mutableStateOf("fileinto") } var action by rememberSaveable { mutableStateOf("fileinto") }
var targetFolder by rememberSaveable(folders) { mutableStateOf(folders.firstOrNull().orEmpty()) } 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( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -102,7 +138,7 @@ fun MailSieveFiltersSheet(
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.94f) .fillMaxWidth(0.94f)
.heightIn(max = 640.dp), .heightIn(max = 660.dp),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = Color.White, color = Color.White,
) { ) {
@@ -128,19 +164,25 @@ fun MailSieveFiltersSheet(
else -> { else -> {
if (rules.isEmpty()) { if (rules.isEmpty()) {
Text( Text(
"Фильтров из приложения пока нет. Правила, созданные в веб-версии, не показываются и не изменяются.", "Фильтров пока нет.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary, color = F7Colors.TextSecondary,
) )
} }
rules.forEach { rule -> rules.forEachIndexed { index, rule ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 36.dp) .heightIn(min = 36.dp)
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(F7Colors.SurfaceMuted) .background(
.padding(horizontal = 10.dp, vertical = 8.dp), 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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -153,32 +195,36 @@ fun MailSieveFiltersSheet(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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( Text(
"$fieldLabel содержит «${rule.contains}» → $actionLabel", ruleSummary(rule) +
if (!rule.isSimple) " (сложное — правится в вебе)" else "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary, color = F7Colors.TextSecondary,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
Switch(
checked = rule.enable,
onCheckedChange = { onToggle(index, it) },
enabled = !saving,
colors = SwitchDefaults.colors(checkedTrackColor = F7Colors.Primary),
)
Icon( Icon(
imageVector = Icons.Filled.Close, imageVector = Icons.Filled.Close,
contentDescription = "Удалить фильтр", contentDescription = "Удалить фильтр",
tint = F7Colors.TextSecondary, tint = F7Colors.TextSecondary,
modifier = Modifier modifier = Modifier
.size(16.dp) .size(16.dp)
.clickable(enabled = !saving) { onDelete(rule.id) }, .clickable(enabled = !saving) {
if (editingIndex == index) clearForm()
onDelete(index)
},
) )
} }
} }
Text( Text(
"Новый фильтр", if (editingIndex >= 0) "Изменить фильтр" else "Новый фильтр",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary, color = F7Colors.TextPrimary,
@@ -225,22 +271,27 @@ fun MailSieveFiltersSheet(
Text(error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium) Text(error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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( F7PrimaryButton(
text = if (saving) "Сохранение…" else "Создать", text = when {
saving -> "Сохранение…"
editingIndex >= 0 -> "Сохранить"
else -> "Создать"
},
onClick = { onClick = {
onAdd( val rule = MailFilterRule.simple(
MailSieveRule(
id = UUID.randomUUID().toString().take(8),
name = name.trim().ifBlank { "Фильтр" }, name = name.trim().ifBlank { "Фильтр" },
field = field, field = field,
contains = contains.trim(), contains = contains.trim(),
action = action, action = action,
targetFolder = if (action == "fileinto") targetFolder else "", targetFolder = if (action == "fileinto") targetFolder else "",
),
) )
name = "" if (editingIndex >= 0) onUpdate(editingIndex, rule) else onAdd(rule)
contains = "" clearForm()
}, },
enabled = !saving && contains.isNotBlank() && enabled = !saving && contains.isNotBlank() &&
(action != "fileinto" || targetFolder.isNotBlank()), (action != "fileinto" || targetFolder.isNotBlank()),
@@ -50,9 +50,9 @@ data class MailUiState(
val createEventSheetOpen: Boolean = false, val createEventSheetOpen: Boolean = false,
val createTargets: List<MailTargetList> = emptyList(), val createTargets: List<MailTargetList> = emptyList(),
val creatingFromMessage: Boolean = false, val creatingFromMessage: Boolean = false,
// Sieve-фильтры (паритет живого веба) // Фильтры почты (официальный API NC Mail — общие с вебом)
val sieveAccountId: Int? = null, val sieveAccountId: Int? = null,
val sieveRules: List<MailSieveRule> = emptyList(), val sieveRules: List<MailFilterRule> = emptyList(),
val sieveLoading: Boolean = false, val sieveLoading: Boolean = false,
val sieveSaving: Boolean = false, val sieveSaving: Boolean = false,
val sieveError: String? = null, val sieveError: String? = null,
@@ -1105,7 +1105,7 @@ class MailViewModel(
} }
} }
// --- Sieve-фильтры --- // --- Фильтры почты (общие с вебом, API /api/filter/{accountId}) ---
fun openSieveFilters(session: AuthSession, accountId: Int) { fun openSieveFilters(session: AuthSession, accountId: Int) {
_state.update { _state.update {
@@ -1113,12 +1113,12 @@ class MailViewModel(
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { runCatching {
MailSieveScript.parseRules(repository.getSieveScript(session, accountId)) repository.getMailFilters(session, accountId)
}.onSuccess { rules -> }.onSuccess { rules ->
_state.update { it.copy(sieveRules = rules, sieveLoading = false) } _state.update { it.copy(sieveRules = rules, sieveLoading = false) }
}.onFailure { t -> }.onFailure { t ->
_state.update { _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) } _state.update { it.copy(sieveAccountId = null, sieveError = null) }
} }
fun addSieveRule(session: AuthSession, rule: MailSieveRule) { fun addFilterRule(session: AuthSession, rule: MailFilterRule) {
saveSieveRules(session) { it + rule } saveFilterRules(session) { it + rule }
} }
fun deleteSieveRule(session: AuthSession, ruleId: String) { fun updateFilterRule(session: AuthSession, index: Int, rule: MailFilterRule) {
saveSieveRules(session) { rules -> rules.filterNot { it.id == ruleId } } 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, session: AuthSession,
transform: (List<MailSieveRule>) -> List<MailSieveRule>, transform: (List<MailFilterRule>) -> List<MailFilterRule>,
) { ) {
val accountId = _state.value.sieveAccountId ?: return val accountId = _state.value.sieveAccountId ?: return
// Правки применяем к ТЕКУЩЕМУ локальному списку (он же только что с сервера);
// сервер хранит фильтры единым массивом — PUT перезаписывает секцию приложения Mail.
val rules = transform(_state.value.sieveRules)
_state.update { it.copy(sieveSaving = true, sieveError = null) } _state.update { it.copy(sieveSaving = true, sieveError = null) }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { runCatching {
// Перечитываем скрипт перед записью — не затираем изменения из веба. repository.putMailFilters(session, accountId, rules)
val current = repository.getSieveScript(session, accountId) repository.getMailFilters(session, accountId)
val rules = transform(MailSieveScript.parseRules(current)) }.onSuccess { fresh ->
repository.putSieveScript(session, accountId, MailSieveScript.renderScript(current, rules)) _state.update { it.copy(sieveRules = fresh, sieveSaving = false) }
rules
}.onSuccess { rules ->
_state.update { it.copy(sieveRules = rules, sieveSaving = false) }
}.onFailure { t -> }.onFailure { t ->
_state.update { it.copy(sieveSaving = false, sieveError = t.message ?: "Ошибка сохранения") } _state.update { it.copy(sieveSaving = false, sieveError = t.message ?: "Ошибка сохранения") }
} }
@@ -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)
}
}
@@ -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)
}
}