diff --git a/feature/mail/build.gradle b/feature/mail/build.gradle index b763be8..2d29240 100644 --- a/feature/mail/build.gradle +++ b/feature/mail/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation project(':feature:calendar') implementation libs.coroutines.android implementation libs.json + testImplementation libs.junit def composeBom = platform(libs.compose.bom) implementation composeBom implementation libs.compose.ui 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 331c4d9..bb9f664 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,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 = + 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)}\";" + } + "$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("\"", "\\\"") +} 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 30ed5b4..43b244a 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 @@ -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) { diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSettingsScreens.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSettingsScreens.kt index 11826d1..104c11c 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSettingsScreens.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSettingsScreens.kt @@ -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, onBack: () -> Unit, onSave: (MailAccountSettings) -> Unit, + onOpenSieveFilters: (() -> Unit)? = null, ) { var draft by remember { mutableStateOf(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, 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 new file mode 100644 index 0000000..e533558 --- /dev/null +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailSieveFiltersSheet.kt @@ -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, + folders: List, + 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), + ) + } + } + } + } + } + } +} 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 e77e64c..dca4dc1 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,6 +50,12 @@ data class MailUiState( val createEventSheetOpen: Boolean = false, val createTargets: List = emptyList(), val creatingFromMessage: Boolean = false, + // Sieve-фильтры (паритет живого веба) + val sieveAccountId: Int? = null, + val sieveRules: List = 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) -> List, + ) { + 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 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 new file mode 100644 index 0000000..bac88d9 --- /dev/null +++ b/feature/mail/src/test/java/ru/forbion/f7cloud/feature/mail/MailSieveScriptTest.kt @@ -0,0 +1,62 @@ +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) + } +}