feat(mail): sieve-фильтры — список и конструктор «Новый фильтр»

Паритет живого веба (модалка «Новый фильтр»):
- API: GET/PUT /apps/mail/api/sieve/active/{accountId} (MailRepository).
- Наши правила живут в маркированных блоках # F7MOBILE-BEGIN/END
  внутри активного скрипта; пользовательские/веб-правила НЕ трогаем,
  перед каждой записью скрипт перечитывается (не затираем изменения
  из веба). require ["fileinto","imap4flags"] — в начале скрипта.
- Конструктор: если [От/Тема/Кому] содержит X → [переместить в папку /
  пометить прочитанным / удалить]; список правил с удалением.
- Вход: Настройки почты → аккаунт → «Фильтры».
- MailSieveScript покрыт юнит-тестами (roundtrip, сохранение чужого
  скрипта, экранирование кавычек, все действия) — 4 зелёных.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
b-dev-mobile
2026-07-10 04:15:17 +00:00
parent 2df46667e0
commit ab864aa7d7
7 changed files with 518 additions and 0 deletions
@@ -967,7 +967,108 @@ class MailRepository {
}.sortedByDescending { it.updatedAt }
}
// --- Sieve-фильтры (паритет живого веба, модалка «Новый фильтр») ---
fun getSieveScript(session: AuthSession, accountId: Int): String {
val client = authedClient(session)
val json = getJson(client, "${apiBase(session)}/sieve/active/$accountId")
return (json as? JSONObject)?.optString("script").orEmpty()
}
fun putSieveScript(session: AuthSession, accountId: Int, script: String) {
val client = authedClient(session)
val payload = JSONObject().put("script", script).toString()
val request = Request.Builder()
.url("${apiBase(session)}/sieve/active/$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 ""}")
}
}
}
companion object {
const val PAGE_SIZE = 20
}
}
/** Правило фильтра, которым управляет приложение (наш маркированный блок в 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`.
* Остальной текст скрипта (правила из веба/пользовательские) не трогаем.
*/
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),
)
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)}\";"
}
"$BEGIN$meta\nif $condition {\n$body\n}\n$END"
}
// require обязан быть в начале скрипта — наш заголовок идёт первым.
return listOf(header + blocks, user).filter { it.isNotBlank() }.joinToString("\n\n") + "\n"
}
private fun sieveEscape(value: String): String =
value.replace("\\", "\\\\").replace("\"", "\\\"")
}
@@ -249,6 +249,21 @@ fun MailScreen(
onSettingsOpenChange(false)
},
onSwipeBack = { mailNavigateBack() },
onOpenSieveFilters = { accountId -> vm.openSieveFilters(session, accountId) },
)
MailSieveFiltersSheet(
visible = state.sieveAccountId != null,
rules = state.sieveRules,
folders = state.mailboxes
.filter { it.accountId == state.sieveAccountId && !it.isInbox && it.specialRole.isNullOrBlank() }
.map { it.displayName },
loading = state.sieveLoading,
saving = state.sieveSaving,
error = state.sieveError,
onDismiss = { vm.closeSieveFilters() },
onAdd = { rule -> vm.addSieveRule(session, rule) },
onDelete = { id -> vm.deleteSieveRule(session, id) },
)
BackHandler(enabled = mailCanGoBack) {
@@ -77,6 +77,7 @@ fun MailSettingsSheet(
onDeleteTextBlock: (Int) -> Unit,
onDismiss: () -> Unit,
onSwipeBack: () -> Unit = onDismiss,
onOpenSieveFilters: ((Int) -> Unit)? = null,
) {
if (!visible) return
LaunchedEffect(visible, editingAccountId) {
@@ -102,6 +103,9 @@ fun MailSettingsSheet(
mailboxes = mailboxes.filter { it.accountId == editingAccountId },
onBack = onAccountSettingsBack,
onSave = onSaveAccountSettings,
onOpenSieveFilters = onOpenSieveFilters?.let { open ->
{ open(editingAccountId) }
},
)
} else {
MailAppSettingsScreen(
@@ -517,6 +521,7 @@ private fun MailAccountSettingsScreen(
mailboxes: List<MailMailbox>,
onBack: () -> Unit,
onSave: (MailAccountSettings) -> Unit,
onOpenSieveFilters: (() -> Unit)? = null,
) {
var draft by remember { mutableStateOf<MailAccountSettings?>(null) }
LaunchedEffect(settings) {
@@ -567,6 +572,27 @@ private fun MailAccountSettingsScreen(
draft!!.quotaPercentage?.let { quota ->
MailSettingsInfoRow("Квота", "$quota%")
}
onOpenSieveFilters?.let { open ->
// Sieve-фильтры (паритет живого веба)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(F7Colors.SurfaceMuted)
.clickable(onClick = open)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Фильтры",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
)
Text("", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextSecondary)
}
}
MailSettingsTextArea(
label = "Подпись",
value = draft!!.signature,
@@ -0,0 +1,255 @@
package ru.forbion.f7cloud.feature.mail
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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). */
@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),
)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
}
}
/**
* Модалка «Фильтры» (sieve) — паритет живого веба: список правил приложения
* + конструктор «Новый фильтр» (поле содержит значение → действие).
*/
@Composable
fun MailSieveFiltersSheet(
visible: Boolean,
rules: List<MailSieveRule>,
folders: List<String>,
loading: Boolean,
saving: Boolean,
error: String?,
onDismiss: () -> Unit,
onAdd: (MailSieveRule) -> Unit,
onDelete: (String) -> Unit,
) {
if (!visible) return
var name by rememberSaveable { mutableStateOf("") }
var field by rememberSaveable { mutableStateOf("subject") }
var contains by rememberSaveable { mutableStateOf("") }
var action by rememberSaveable { mutableStateOf("fileinto") }
var targetFolder by rememberSaveable(folders) { mutableStateOf(folders.firstOrNull().orEmpty()) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.94f)
.heightIn(max = 640.dp),
shape = RoundedCornerShape(16.dp),
color = Color.White,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"Фильтры",
style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
when {
loading -> Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
else -> {
if (rules.isEmpty()) {
Text(
"Фильтров из приложения пока нет. Правила, созданные в веб-версии, не показываются и не изменяются.",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
rules.forEach { rule ->
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 36.dp)
.clip(RoundedCornerShape(10.dp))
.background(F7Colors.SurfaceMuted)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
rule.name.ifBlank { "Без названия" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
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",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Удалить фильтр",
tint = F7Colors.TextSecondary,
modifier = Modifier
.size(16.dp)
.clickable(enabled = !saving) { onDelete(rule.id) },
)
}
}
Text(
"Новый фильтр",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
)
F7OutlinedField(value = name, onValueChange = { name = it }, label = "Название")
Text("Если", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FIELD_OPTIONS.forEach { (value, label) ->
OptionChip(label, selected = field == value) { field = value }
}
}
F7OutlinedField(value = contains, onValueChange = { contains = it }, label = "Содержит")
Text("То", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ACTION_OPTIONS.forEach { (value, label) ->
OptionChip(label, selected = action == value) { action = value }
}
}
if (action == "fileinto" && folders.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
folders.forEach { folder ->
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 32.dp)
.clip(RoundedCornerShape(10.dp))
.background(
if (folder == targetFolder) F7Colors.PrimaryLight else F7Colors.SurfaceMuted,
)
.clickable { targetFolder = folder }
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
folder,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
}
}
}
}
if (!error.isNullOrBlank()) {
Text(error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
F7SecondaryButton("Закрыть", onClick = onDismiss, modifier = Modifier.weight(1f))
F7PrimaryButton(
text = if (saving) "Сохранение…" 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 "",
),
)
name = ""
contains = ""
},
enabled = !saving && contains.isNotBlank() &&
(action != "fileinto" || targetFolder.isNotBlank()),
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
@@ -50,6 +50,12 @@ data class MailUiState(
val createEventSheetOpen: Boolean = false,
val createTargets: List<MailTargetList> = emptyList(),
val creatingFromMessage: Boolean = false,
// Sieve-фильтры (паритет живого веба)
val sieveAccountId: Int? = null,
val sieveRules: List<MailSieveRule> = emptyList(),
val sieveLoading: Boolean = false,
val sieveSaving: Boolean = false,
val sieveError: String? = null,
)
class MailViewModel(
@@ -1099,6 +1105,58 @@ class MailViewModel(
}
}
// --- Sieve-фильтры ---
fun openSieveFilters(session: AuthSession, accountId: Int) {
_state.update {
it.copy(sieveAccountId = accountId, sieveRules = emptyList(), sieveLoading = true, sieveError = null)
}
viewModelScope.launch(Dispatchers.IO) {
runCatching {
MailSieveScript.parseRules(repository.getSieveScript(session, accountId))
}.onSuccess { rules ->
_state.update { it.copy(sieveRules = rules, sieveLoading = false) }
}.onFailure { t ->
_state.update {
it.copy(sieveLoading = false, sieveError = t.message ?: "Sieve недоступен")
}
}
}
}
fun closeSieveFilters() {
_state.update { it.copy(sieveAccountId = null, sieveError = null) }
}
fun addSieveRule(session: AuthSession, rule: MailSieveRule) {
saveSieveRules(session) { it + rule }
}
fun deleteSieveRule(session: AuthSession, ruleId: String) {
saveSieveRules(session) { rules -> rules.filterNot { it.id == ruleId } }
}
private fun saveSieveRules(
session: AuthSession,
transform: (List<MailSieveRule>) -> List<MailSieveRule>,
) {
val accountId = _state.value.sieveAccountId ?: return
_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) }
}.onFailure { t ->
_state.update { it.copy(sieveSaving = false, sieveError = t.message ?: "Ошибка сохранения") }
}
}
}
companion object {
private const val MARK_READ_DELAY_MS = 2000L
private const val FOLDER_CACHE_TTL_MS = 15 * 60 * 1000L