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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user