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 }
}
// --- 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 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<MailFilterRule>) {
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<MailSieveRule> =
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)}\";"
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))
}
}
"$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<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(
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),
)
}
}
}
@@ -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) {
@@ -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<MailSieveRule>,
rules: List<MailFilterRule>,
folders: List<String>,
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()),
@@ -50,9 +50,9 @@ data class MailUiState(
val createEventSheetOpen: Boolean = false,
val createTargets: List<MailTargetList> = emptyList(),
val creatingFromMessage: Boolean = false,
// Sieve-фильтры (паритет живого веба)
// Фильтры почты (официальный API NC Mail — общие с вебом)
val sieveAccountId: Int? = null,
val sieveRules: List<MailSieveRule> = emptyList(),
val sieveRules: List<MailFilterRule> = 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<MailSieveRule>) -> List<MailSieveRule>,
transform: (List<MailFilterRule>) -> List<MailFilterRule>,
) {
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 ?: "Ошибка сохранения") }
}