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 <tdvu@linagora.com>
This commit is contained in:
@@ -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`
|
||||
@@ -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<EmailId>
|
||||
```
|
||||
|
||||
* Existing interactors:
|
||||
|
||||
* `MarkAsEmailReadInteractor` (single email)
|
||||
* `MarkAsMultipleEmailReadInteractor` (bulk email)
|
||||
|
||||
* Existing repository:
|
||||
|
||||
```dart
|
||||
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:
|
||||
|
||||
```text
|
||||
ThreadId → List<EmailInThreadDetailInfo>
|
||||
```
|
||||
|
||||
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<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
|
||||
|
||||
```dart
|
||||
class ThreadExpansionResult {
|
||||
final List<EmailInThreadDetailInfo> emailThreadInfos;
|
||||
final Map<ThreadId, Object> errors;
|
||||
|
||||
ThreadExpansionResult({
|
||||
required this.emailThreadInfos,
|
||||
required this.errors,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```dart
|
||||
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
|
||||
|
||||
```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<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
|
||||
|
||||
```dart
|
||||
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
|
||||
|
||||
```dart
|
||||
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:
|
||||
|
||||
```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<ThreadId, List<EmailInThreadDetailInfo>>
|
||||
```
|
||||
|
||||
* 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)
|
||||
```
|
||||
Reference in New Issue
Block a user