Все значимые изменения в RMS Mail будут задокументированы в этом файле.
\Seen Реализация флага — двунаправленная синхронизация состояния прочитанностиIMAP \Seen флаг полностью игнорировался в обоих направлениях. Письма всегда вставлялись как is_read=false(за исключением папки Отправленные), и локальные изменения состояния прочитанности никогда не отправлялись на IMAP-сервер.
IMAP → RMS (чтение \Seen):
fetcher.go: ProcessMessage и ProcessMessageToFolder теперь разбирают msg.Flags и устанавливают email.IsRead = true когда \Seen присутствует. Ранее всегда false.Атомарный ON CONFLICT (защита от состояния гонки):
SaveEmailToFolder и SaveEmail в PostgreSQL и SQLite: ON CONFLICT ... DO UPDATE SET is_read = CASE WHEN emails.is_dirty_locally THEN emails.is_read ELSE EXCLUDED.is_read END. Если пользователь изменил состояние прочитанности локально (is_dirty_locally=true), значение на сервере не перезаписывается. В противном случае синхронизация обновляет из IMAP \Seen.RMS → IMAP (отправка \Seen):
worker.go: syncFlags() — после основного цикла синхронизации запрашивает GetDirtyEmails (LIMIT 500), группирует по статусу прочитанности/непрочитанности, отправляет пакетные IMAP STORE команды (200 UID за пакет через StoreFlagsAdd/StoreFlagsDel).ClearDirtyFlag(): сбрасывает is_dirty_locally=false после успешной синхронизации флагов IMAP.webhook_retry_queue таблица + фоновый Go-тикер) для вебхуков в Mono-версии. Это гарантирует, что повторы вебхуков переживают перезапуски контейнеров, делая надежность Mono сопоставимой с очередью Redis ZSET в Unified-версии.GetDirtyEmails(ctx, accountID) ([]Email, error): возвращает письма с is_dirty_locally=true, исключая черновики (отдельный GetDirtyDrafts путь)ClearDirtyFlag(ctx, emailID) error: очищает грязный флаг после успешной IMAP STORESyncStore интерфейс: оба метода добавлены в интерфейс пакета sync вместе с существующим GetDirtyDraftsisLicensed): полностью переписан без блокировок. Теперь он безусловно сразу возвращает кэшированное значение и асинхронно получает актуальный статус БД в фоне. Отказоустойчивый режим при начальной загрузке гарантирует отсутствие блокировки при первом запросе.goroutine) и безусловно возвращает {"status": "ok"} немедленно фронтенду. Это предотвращает зависание UI, если сервер лицензий RMS недоступен.Фоновое параллельное построение индексов (CREATE INDEX CONCURRENTLY по 64 разделам) насытили дисковый ввод-вывод и истощили пул соединений PostgreSQL. Это вызвало тайм-ауты несвязанных API-запросов, в частности isLicensed и GetUnreadCount—до тайм-аута.
isLicensed тайм-ауты некорректно срабатывали is_locked: true на фронтенде, мгновенно выгоняя пользователей. Реализован sync.RWMutex кэш с TTL 30 секунд и 5-минутным плавным откатом, если запрос к БД не удался.time.Sleep(2 * time.Second) пауза между созданиями индексов разделов FTS в RunBackgroundOptimizations чтобы дать базе данных передохнуть и обслуживать запросы пользователей.buildFTSPartitions вызов в main.go с store.RunBackgroundOptimizations(context.Background()).invalid operation: store ... is not an interface. Драйвер SQLite возвращает конкретный тип указателя *sqlite.Storage вместо интерфейса, вызывая RunBackgroundOptimizations утверждение типа не удаётся при компиляции. Обёрнуто с помощью any(store) чтобы удовлетворить компилятор Go для обеих редакций.Имена групп без truncate выталкивали цветную точку за пределы overflow-hidden контейнера. Исправлено: shrink-0 на стрелке/точке/счетчике, min-w-0 truncate на имени, is_locked значок перемещен за пределы span имени.
Коррелированный подзапрос заменен на WITH inbox_unread AS (...) CTE. 3 подзапроса групп × 250K писем → один hash join по ~500 отфильтрованным строкам.
\Seen ON CONFLICT ранее перезаписывал состояние сервераИсходный ON CONFLICT DO UPDATE SET is_read = emails.is_read всегда сохранял значение БД, даже при первой синхронизации, когда \Seen должен быть источником истины. Новый CASE WHEN логика правильно отличает первую синхронизацию от локальных изменений.