* 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 <tdvu@linagora.com>
13 KiB
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:
- Session capability indicates
collapseThreadsis supported. - 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:
List<EmailId>
-
Existing interactors:
MarkAsEmailReadInteractor(single email)MarkAsMultipleEmailReadInteractor(bulk email)
-
Existing repository:
abstract class ThreadDetailRepository {
Future<List<EmailInThreadDetailInfo>> 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:
ThreadId → List<EmailInThreadDetailInfo>
Key challenges:
- N+1 API calls (
getThreadById) - Partial failures (one thread fails while others succeed)
- Duplicate
EmailInThreadDetailInfos 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
User Action (UI)
↓
Thread-aware Interactor (Stream<UIState>)
↓
ThreadExpansionService
↓
List<EmailInThreadDetailInfo> (deduplicated & pagination-safe)
↓
Existing Email Interactor (Stream)
↓
UI State Update (Optimistic + Server Sync)
1. Thread Expansion Service
Responsibility
-
Convert
List<ThreadId>→List<EmailInThreadDetailInfo> -
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
class ThreadExpansionResult {
final List<EmailInThreadDetailInfo> emailThreadInfos;
final Map<ThreadId, Object> errors;
ThreadExpansionResult({
required this.emailThreadInfos,
required this.errors,
});
}
Implementation
class ThreadExpansionService {
final ThreadDetailRepository threadDetailRepository;
final Map<ThreadId, List<EmailInThreadDetailInfo>> _cache = {};
ThreadExpansionService(this.threadDetailRepository);
Future<ThreadExpansionResult> expandThreads({
required List<ThreadId> threadIds,
required Session session,
required AccountId accountId,
required MailboxId sentMailboxId,
required String ownEmailAddress,
}) async {
final emailThreadInfos = <EmailInThreadDetailInfo>{};
final errors = <ThreadId, Object>{};
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
import 'package:jmap_dart_client/jmap/core/id.dart';
import 'package:jmap_dart_client/jmap/core/error/set_error.dart';
class ThreadActionResult {
final List<EmailInThreadDetailInfo> success;
final Map<Id, SetError> actionErrors;
final Map<ThreadId, Object> expansionErrors;
ThreadActionResult({
required this.success,
required this.actionErrors,
required this.expansionErrors,
});
}
3. Example Implementations
3.1 MarkAsThreadReadInteractor
class MarkAsThreadReadInteractor {
final ThreadExpansionService expansionService;
final EmailRepository emailRepository;
MarkAsThreadReadInteractor(
this.expansionService,
this.emailRepository,
);
Stream<Either<Failure, Success>> execute({
required Session session,
required AccountId accountId,
required MailboxId sentMailboxId,
required String ownEmailAddress,
required ReadActions readAction,
List<ThreadId> threadIds = const [],
List<EmailInThreadDetailInfo> 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
class MoveThreadInteractor {
final ThreadExpansionService expansionService;
final EmailRepository emailRepository;
MoveThreadInteractor(
this.expansionService,
this.emailRepository,
);
Stream<Either<Failure, Success>> execute({
required Session session,
required AccountId accountId,
required MailboxId sentMailboxId,
required String ownEmailAddress,
required MailboxId destinationMailboxId,
List<ThreadId> threadIds = const [],
List<EmailInThreadDetailInfo> 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:
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
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:
updateEmailFlagByEmailIds(...)
Key Principle
UI is updated by mutating
PresentationEmail.keywordsimmediately
Optimistic Update Flow
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
controller.updateEmailFlagByEmailIds(
emailIds,
readAction: ReadActions.markAsRead,
);
OR
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
controller.updateEmailFlagByEmailIds(
emailIds,
readAction: ReadActions.markAsUnread, // rollback
);
⚠️ Partial Success Handling
When:
Some emailIds succeed, some fail
We perform:
final failedIds = allIds - successIds;
controller.updateEmailFlagByEmailIds(
failedIds,
readAction: ReadActions.markAsUnread,
);
🔄 Keyword-based Update Model
Core Mechanism
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:
currentEmails.refresh();
ensures:
-
Immediate UI re-render
-
Works for both:
- mailbox list
- search result list
🔗 Thread Detail Synchronization
When updating single email:
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:
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
final allEmailThreadInfos = {
...emailThreadInfos,
...expandedEmailThreadInfos,
};
Cache
Map<ThreadId, List<EmailInThreadDetailInfo>>
- Reduces repeated calls to
getThreadById - Improves performance for repeated actions
Parallel Execution
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 )
collapseThreadsis toggled
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 callingclearCache()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
Thread Action Flow:
ThreadIds
→ expandThreads (parallel + cache + error-isolated)
→ extract EmailInThreadDetailInfo
→ deduplicate
→ call existing Email Interactor
→ return (success + actionErrors + expansionErrors)