From 93c96a2b8382b9a56bb3af4e5bf0410430beed21 Mon Sep 17 00:00:00 2001 From: "Dat H. Pham" Date: Fri, 27 Mar 2026 09:44:45 +0700 Subject: [PATCH] ADR-71 CollapseThread (#4380) * ADR-71 CollapseThread * fixup! ADR-71 CollapseThread * docs(ADR-0072): Add thread-aware bulk actions for collapseThreads in EmailList * fixup! docs(ADR-0072): Add thread-aware bulk actions for collapseThreads in EmailList * fixup! fixup! docs(ADR-0072): Add thread-aware bulk actions for collapseThreads in EmailList * fixup! fixup! fixup! docs(ADR-0072): Add thread-aware bulk actions for collapseThreads in EmailList * fixup! fixup! fixup! fixup! docs(ADR-0072): Add thread-aware bulk actions for collapseThreads in EmailList * fixup! fixup! fixup! fixup! fixup! docs(ADR-0072): Add thread-aware bulk actions for collapseThreads in EmailList * fixup! fixup! fixup! fixup! fixup! fixup! docs(ADR-0072): Add thread-aware bulk actions for collapseThreads in EmailList * fixup! fixup! fixup! fixup! fixup! fixup! fixup! docs(ADR-0072): Add thread-aware bulk actions for collapseThreads in EmailList --------- Co-authored-by: dab246 --- .../0071-collapse-threads-in-email-query.md | 64 ++ ...ulk-actions-email-list-collapse-threads.md | 633 ++++++++++++++++++ 2 files changed, 697 insertions(+) create mode 100644 docs/adr/0071-collapse-threads-in-email-query.md create mode 100644 docs/adr/0072-thread-aware-bulk-actions-email-list-collapse-threads.md diff --git a/docs/adr/0071-collapse-threads-in-email-query.md b/docs/adr/0071-collapse-threads-in-email-query.md new file mode 100644 index 000000000..15b9bdaaf --- /dev/null +++ b/docs/adr/0071-collapse-threads-in-email-query.md @@ -0,0 +1,64 @@ +# 0071 - Enable collapseThreads in Email/query + +Date: 2026-03-12 + +## Status + +Proposed + +## Context + +JMAP `Email/query` supports `collapseThreads` (RFC 8621 §4.4). When `true`, server returns only the **latest email per thread**, avoiding duplicate rows in the mail list. + +Currently, our app does **not** set `collapseThreads`. When thread is enabled, users see multiple emails from the same thread as separate rows. + +### Two Query Paths (see ADR-0070) + +| Path | `FORCE_EMAIL_QUERY` | Method | Cache | +|------|---------------------|--------|-------| +| Force query | `true` | `forceQueryAllEmailsForWeb()` | No local cache — server only | +| Cache-first | `false` | `getAllEmail()` | Local DB first, then sync | + +### Problem: Cache Inconsistency on Toggle + +On the cache-first path, the local DB reflects the `collapseThreads` mode it was built under. When thread mode changes: + +- **OFF → ON**: Cache has individual emails (5 rows) but server now returns collapsed (3 rows) → stale first yield +- **ON → OFF**: Cache has collapsed emails (3 rows) but server now returns all individuals (5 rows) → missing emails in first yield + +Force-query path has **no problem** — always queries server directly. + +## Decision + +### 1. Set `collapseThreads: true` in Email/query When Thread Is Enabled + +In `MailAPIMixin.fetchAllEmail()` and `ThreadAPI.searchEmails()`, pass `collapseThreads: true` when thread is enabled. + +### 2. Clear Email Cache on Thread Setting Toggle + +Reuse the existing thread-enabled setting. **No new flag needed.** When thread is toggled (enable ↔ disable), clear email cache immediately in the toggle handler. Next `getAllEmail()` will rebuild cache under correct mode. + +### 3. No Change for Force-Query Path + +Already server-only. Just pass `collapseThreads: true` in the query. + +## Consequences + +**Positive**: Correct thread display, reduced data per page, spec-compliant. + +**Negative**: One-time full reload on each toggle; toggle handler coupled with cache management. + +## Implementation Steps + +1. Add `collapseThreads` to `QueryEmailMethod` in `MailAPIMixin.fetchAllEmail()` and `ThreadAPI.searchEmails()` +2. Pass thread-enabled status from Settings to query builder +3. Clear email cache in thread setting toggle handler +4. Update `GetEmailsInMailboxInteractor` and related interactors to propagate thread status + +## References + +- [RFC 8621 §4.4](https://www.rfc-editor.org/rfc/rfc8621#section-4.4) +- [ADR-0070](./0070-sync-strategy-disappearing-emails.md) +- `lib/features/base/mixin/mail_api_mixin.dart` — `fetchAllEmail()` +- `lib/features/thread/data/repository/thread_repository_impl.dart` — `getAllEmail()` +- `lib/features/thread/domain/usecases/get_emails_in_mailbox_interactor.dart` diff --git a/docs/adr/0072-thread-aware-bulk-actions-email-list-collapse-threads.md b/docs/adr/0072-thread-aware-bulk-actions-email-list-collapse-threads.md new file mode 100644 index 000000000..2a44553fb --- /dev/null +++ b/docs/adr/0072-thread-aware-bulk-actions-email-list-collapse-threads.md @@ -0,0 +1,633 @@ +# 0072 - Thread-aware bulk actions for EmailList with collapseThreads using expansion and dedicated Interactors + +Date: 2026-03-17 + +## Status + +Proposed + +## Context + +After enabling `collapseThreads = true` (ADR-0071), each item in the `EmailList` represents a **ThreadId** instead of an individual `EmailId`. + +### Preconditions (from ADR-0071) + +Thread-aware flow is enabled only when: +1. Session capability indicates `collapseThreads` is supported. +2. User Settings toggle for thread mode is enabled. + +If either condition is false, keep existing EmailId-based flow. + +However, the current system: + +* All actions (`markAsRead`, `move`, `star`, etc.) operate on: + +```dart +List +``` + +* Existing interactors: + + * `MarkAsEmailReadInteractor` (single email) + * `MarkAsMultipleEmailReadInteractor` (bulk email) + +* Existing repository: + +```dart +abstract class ThreadDetailRepository { + Future> getThreadById( + ThreadId threadId, + Session session, + AccountId accountId, + MailboxId sentMailboxId, + String ownEmailAddress, + ); +} +``` + +## Problem + +When users perform actions on: + +* a single thread +* multiple threads (multi-select) +* or a mix of threads and emails + +the system must resolve: + +```text +ThreadId → List +``` + +Key challenges: + +* N+1 API calls (`getThreadById`) +* Partial failures (one thread fails while others succeed) +* Duplicate `EmailInThreadDetailInfo`s when merging results +* High latency with large selections +* Existing interactors are not designed for thread-level inputs + +## Decision + +### Adopt: + +> **Thread-aware Interactors + Thread Expansion Service (cached, parallel, error-isolated) + reuse existing Email Interactors** + +## Architecture Overview + +```text +User Action (UI) + ↓ +Thread-aware Interactor (Stream) + ↓ +ThreadExpansionService + ↓ +List (deduplicated & pagination-safe) + ↓ +Existing Email Interactor (Stream) + ↓ +UI State Update (Optimistic + Server Sync) +``` + +## 1. Thread Expansion Service + +### Responsibility + +* Convert `List` → `List` +* Reuse `ThreadDetailRepository.getThreadById` +* Handle: + + * caching + * parallel execution + * error isolation + * pagination awareness + +### Pagination Awareness + +We've already handled pagination for the `Email/set` and `Email/get` methods +in the mixins `BatchSetEmailProcessingMixin` and `BatchGetEmailProcessingMixin`, +so we just need to call and use them. + +### Data Model + +```dart +class ThreadExpansionResult { + final List emailThreadInfos; + final Map errors; + + ThreadExpansionResult({ + required this.emailThreadInfos, + required this.errors, + }); +} +``` + +### Implementation + +```dart +class ThreadExpansionService { + final ThreadDetailRepository threadDetailRepository; + + final Map> _cache = {}; + + ThreadExpansionService(this.threadDetailRepository); + + Future expandThreads({ + required List threadIds, + required Session session, + required AccountId accountId, + required MailboxId sentMailboxId, + required String ownEmailAddress, + }) async { + final emailThreadInfos = {}; + final errors = {}; + + final futures = threadIds.map((threadId) async { + try { + // Cache hit + if (_cache.containsKey(threadId)) { + emailIds.addAll(_cache[threadId]!); + return; + } + + final threadDetails = await threadDetailRepository.getThreadById( + threadId, + session, + accountId, + sentMailboxId, + ownEmailAddress, + ); + + _cache[threadId] = threadDetails; + emailIds.addAll(threadDetails); + } catch (e) { + // Error isolation + errors[threadId] = e; + } + }); + + await Future.wait(futures); + + return ThreadExpansionResult( + emailThreadInfos: emailThreadInfos.toList(), + errors: errors, + ); + } + + void clearCache() => _cache.clear(); +} +``` + +## 2. Thread-aware Interactor Pattern + +### Principles + +* Do not modify existing email interactors +* Do not duplicate business logic +* Only: + + * expand threads + * delegate to existing email interactors + +### Result Model + +```dart +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/error/set_error.dart'; + +class ThreadActionResult { + final List success; + final Map actionErrors; + final Map expansionErrors; + + ThreadActionResult({ + required this.success, + required this.actionErrors, + required this.expansionErrors, + }); +} +``` + +## 3. Example Implementations + +### 3.1 MarkAsThreadReadInteractor + +```dart +class MarkAsThreadReadInteractor { + final ThreadExpansionService expansionService; + final EmailRepository emailRepository; + + MarkAsThreadReadInteractor( + this.expansionService, + this.emailRepository, + ); + + Stream> execute({ + required Session session, + required AccountId accountId, + required MailboxId sentMailboxId, + required String ownEmailAddress, + required ReadActions readAction, + List threadIds = const [], + List emailThreadInfos = const [], + }) async* { + yield Right(LoadingMarkAsThreadRead()); + + final expansion = await expansionService.expandThreads( + threadIds: threadIds, + session: session, + accountId: accountId, + sentMailboxId: sentMailboxId, + ownEmailAddress: ownEmailAddress, + ); + + final allEmailThreadInfos = { + ...emailThreadInfos, + ...expansion.emailThreadInfos, + }.toList(); + + if (allEmailThreadInfos.isEmpty) { + yield Left(MarkAsThreadReadFailure(ThreadActionResult( + success: [], + actionErrors: {}, + expansionErrors: expansion.errors, + ))); + return; + } + + final result = await emailRepository.markAsRead( + session, + accountId, + allEmailThreadInfos.emailIds, + readAction, + ); + + yield Right(MarkAsThreadReadSuccess(ThreadActionResult( + success: result.emailIdsSuccess, + actionErrors: result.mapErrors, + expansionErrors: expansion.errors, + ))); + } +} +``` + +### 3.2 MoveThreadInteractor + +```dart +class MoveThreadInteractor { + final ThreadExpansionService expansionService; + final EmailRepository emailRepository; + + MoveThreadInteractor( + this.expansionService, + this.emailRepository, + ); + + Stream> execute({ + required Session session, + required AccountId accountId, + required MailboxId sentMailboxId, + required String ownEmailAddress, + required MailboxId destinationMailboxId, + List threadIds = const [], + List emailThreadInfos = const [], + }) async* { + yield Right(LoadingMoveThread()); + + final expansion = await expansionService.expandThreads( + threadIds: threadIds, + session: session, + accountId: accountId, + sentMailboxId: sentMailboxId, + ownEmailAddress: ownEmailAddress, + ); + + final allEmailThreadInfos = { + ...emailThreadInfos, + ...expansion.emailThreadInfos, + }.toList(); + + if (allEmailThreadInfos.isEmpty) { + yield Left(MarkAsThreadReadFailure(ThreadActionResult( + success: [], + actionErrors: {}, + expansionErrors: expansion.errors, + ))); + return; + } + + final moveRequest = MoveToMailboxRequest.fromThreadInfos(threadInfos: allEmailThreadInfos); + + final result = await emailRepository.moveToMailbox(session, accountId, moveRequest); + + yield Right(MarkAsThreadReadSuccess(ThreadActionResult( + success: result.emailIdsSuccess, + actionErrors: result.mapErrors, + expansionErrors: expansion.errors, + ))); + } +} +``` + +## 3. UI Responsiveness + +### Problem + +Delay between: + +```text +User action → Interactor → Server → UI update +``` + +causes: + +* Perceived lag +* Poor UX in bulk/thread actions + +### Decision: Optimistic UI Update + +Immediately update UI before server response. + +### Flow + +```text +User click "Mark as read" + ↓ +UI updates instantly (optimistic) + ↓ +Interactor executes + ↓ +Server response: + - success → keep state + - partial → reconcile + - failure → rollback +``` + +### UI State Strategy + +| Case | Behavior | +| ------- | ------------------ | +| Loading | Already updated UI | +| Success | Confirm | +| Partial | Patch missing | +| Failure | Rollback | + +### Per Email UI Update + +Even when acting on thread: + +👉 UI updates must happen at **Email level**, not Thread only. + +Reason: + +* Thread UI derived from Email states +* Avoid inconsistent UI + +## Implementation Strategy + +Instead of maintaining a separate snapshot system, we **reuse existing domain logic**: + +```dart +updateEmailFlagByEmailIds(...) +``` + +### Key Principle + +> UI is updated by mutating `PresentationEmail.keywords` immediately + +## Optimistic Update Flow + +```text +User click (mark read/star) + ↓ +MailboxDashboardController.updateEmailFlagByEmailIds(...) ← (optimistic) + ↓ +UI updates instantly (via RxList.refresh) + ↓ +Interactor.execute() (async) + ↓ +Server response: + - success → do nothing + - partial → reconcile (optional) + - failure → rollback (via reverse update) +``` + +## Controller-Level Implementation + +### ✅ Optimistic Update Trigger + +```dart +controller.updateEmailFlagByEmailIds( + emailIds, + readAction: ReadActions.markAsRead, +); +``` + +OR + +```dart +controller.updateEmailFlagByEmailIds( + emailIds, + markStarAction: MarkStarAction.markStar, +); +``` + +## 🔁 Rollback Strategy + +Instead of snapshot map, rollback is performed by **inverse operation**: + +| Action | Rollback | +| ---------- | ------------ | +| markAsRead | markAsUnread | +| markStar | unMarkStar | + +### Example + +```dart +controller.updateEmailFlagByEmailIds( + emailIds, + readAction: ReadActions.markAsUnread, // rollback +); +``` + +## ⚠️ Partial Success Handling + +When: + +```text +Some emailIds succeed, some fail +``` + +We perform: + +```dart +final failedIds = allIds - successIds; + +controller.updateEmailFlagByEmailIds( + failedIds, + readAction: ReadActions.markAsUnread, +); +``` + +## 🔄 Keyword-based Update Model + +### Core Mechanism + +```dart +presentationEmail.keywords?[keyword] = true; +presentationEmail.keywords?.remove(keyword); +``` + +This ensures: + +* Fine-grained update (no full object replace) +* No unnecessary rebuilds +* Compatible with JMAP keyword model + +## 📡 UI Sync Behavior + +### Why this works well + +Because: + +```dart +currentEmails.refresh(); +``` + +ensures: + +* Immediate UI re-render +* Works for both: + + * mailbox list + * search result list + +## 🔗 Thread Detail Synchronization + +When updating single email: + +```dart +dispatchThreadDetailUIAction(UpdatedEmailKeywordsAction(...)); +``` + +ensures: + +* Thread detail UI stays consistent +* Avoids mismatch between: + + * EmailList + * ThreadDetail screen + +## ⚠️ Limitations + +### 1. No Snapshot → No True Rollback + +Trade-off: + +* ✅ Simpler implementation +* ❌ Cannot restore original complex state (only inverse action) + +### 2. Concurrent Actions Risk + +Example: + +```text +Action A: mark as read +Action B: mark as unread (before A completes) +``` + +👉 May cause inconsistency + +### 3. WebSocket Override + +If WebSocket pushes state: + +* It may override optimistic state +* This is acceptable (server is source of truth) + +## 5. Key Design Properties + +### Error Isolation + +* Each thread expansion is wrapped in `try/catch` +* Failure of one thread does not affect others + +### Deduplication + +```dart + +final allEmailThreadInfos = { + ...emailThreadInfos, + ...expandedEmailThreadInfos, +}; +``` + +### Cache + +```dart +Map> +``` + +* Reduces repeated calls to `getThreadById` +* Improves performance for repeated actions + +### Parallel Execution + +```dart +await Future.wait(...); +``` + +* Minimizes latency for multi-thread operations + +## 6. Cache Invalidation + +Cache must be cleared when: + +* Mailbox sync occurs +* Thread content changes +* Websocket event (Refresh change invoke ) +* `collapseThreads` is toggled + +```dart +expansionService.clearCache(); +``` + +### Implementation Points + +Hook `expansionService.clearCache()` in: + +- `ThreadController.refreshAllEmail()` after mailbox sync completes (see lib/features/thread/presentation/thread_controller.dart) +- `ThreadController.refreshChangeEmail()` after calling `clearCache()` post-action +- Settings controller's `onCollapseThreadsToggled()` callback (or equivalent setter) +- Thread actions are performed + +## Consequences + +### Positive + +* Reuses all existing email business logic +* No backend changes required +* Scales well for multi-selection +* Supports partial success (better UX) +* Clear separation between Thread and Email domains + +### Negative + +* Increased number of interactors +* Requires careful cache invalidation +* Still involves multiple API calls (mitigated via cache + parallelism) +* Stream-based integration adds complexity (async iteration, result collection, state management) +* Complex request object construction (MoveToMailboxRequest, emailIdsByMailboxId) requires additional context gathering + +## Summary + +```text +Thread Action Flow: + +ThreadIds + → expandThreads (parallel + cache + error-isolated) + → extract EmailInThreadDetailInfo + → deduplicate + → call existing Email Interactor + → return (success + actionErrors + expansionErrors) +```