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:
@@ -34,6 +34,7 @@ dependencies {
|
|||||||
implementation project(':feature:calendar')
|
implementation project(':feature:calendar')
|
||||||
implementation libs.coroutines.android
|
implementation libs.coroutines.android
|
||||||
implementation libs.json
|
implementation libs.json
|
||||||
|
testImplementation libs.junit
|
||||||
def composeBom = platform(libs.compose.bom)
|
def composeBom = platform(libs.compose.bom)
|
||||||
implementation composeBom
|
implementation composeBom
|
||||||
implementation libs.compose.ui
|
implementation libs.compose.ui
|
||||||
|
|||||||
@@ -967,7 +967,108 @@ class MailRepository {
|
|||||||
}.sortedByDescending { it.updatedAt }
|
}.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 {
|
companion object {
|
||||||
const val PAGE_SIZE = 20
|
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)
|
onSettingsOpenChange(false)
|
||||||
},
|
},
|
||||||
onSwipeBack = { mailNavigateBack() },
|
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) {
|
BackHandler(enabled = mailCanGoBack) {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ fun MailSettingsSheet(
|
|||||||
onDeleteTextBlock: (Int) -> Unit,
|
onDeleteTextBlock: (Int) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSwipeBack: () -> Unit = onDismiss,
|
onSwipeBack: () -> Unit = onDismiss,
|
||||||
|
onOpenSieveFilters: ((Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
LaunchedEffect(visible, editingAccountId) {
|
LaunchedEffect(visible, editingAccountId) {
|
||||||
@@ -102,6 +103,9 @@ fun MailSettingsSheet(
|
|||||||
mailboxes = mailboxes.filter { it.accountId == editingAccountId },
|
mailboxes = mailboxes.filter { it.accountId == editingAccountId },
|
||||||
onBack = onAccountSettingsBack,
|
onBack = onAccountSettingsBack,
|
||||||
onSave = onSaveAccountSettings,
|
onSave = onSaveAccountSettings,
|
||||||
|
onOpenSieveFilters = onOpenSieveFilters?.let { open ->
|
||||||
|
{ open(editingAccountId) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MailAppSettingsScreen(
|
MailAppSettingsScreen(
|
||||||
@@ -517,6 +521,7 @@ private fun MailAccountSettingsScreen(
|
|||||||
mailboxes: List<MailMailbox>,
|
mailboxes: List<MailMailbox>,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSave: (MailAccountSettings) -> Unit,
|
onSave: (MailAccountSettings) -> Unit,
|
||||||
|
onOpenSieveFilters: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
var draft by remember { mutableStateOf<MailAccountSettings?>(null) }
|
var draft by remember { mutableStateOf<MailAccountSettings?>(null) }
|
||||||
LaunchedEffect(settings) {
|
LaunchedEffect(settings) {
|
||||||
@@ -567,6 +572,27 @@ private fun MailAccountSettingsScreen(
|
|||||||
draft!!.quotaPercentage?.let { quota ->
|
draft!!.quotaPercentage?.let { quota ->
|
||||||
MailSettingsInfoRow("Квота", "$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(
|
MailSettingsTextArea(
|
||||||
label = "Подпись",
|
label = "Подпись",
|
||||||
value = draft!!.signature,
|
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 createEventSheetOpen: Boolean = false,
|
||||||
val createTargets: List<MailTargetList> = emptyList(),
|
val createTargets: List<MailTargetList> = emptyList(),
|
||||||
val creatingFromMessage: Boolean = false,
|
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(
|
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 {
|
companion object {
|
||||||
private const val MARK_READ_DELAY_MS = 2000L
|
private const val MARK_READ_DELAY_MS = 2000L
|
||||||
private const val FOLDER_CACHE_TTL_MS = 15 * 60 * 1000L
|
private const val FOLDER_CACHE_TTL_MS = 15 * 60 * 1000L
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user