feat: implement snooze feature for IMAP and JMAP
- Add snoozedUntil and snoozedFromMailboxPath to Emails table. - Implement snoozeEmail and wakeUpEmails in EmailRepository. - Update IMAP and JMAP flush logic to handle snooze/unsnooze. - Update sync logic to parse snz: keywords from server. - Add SnoozePicker widget and integrate into UI. - Add unit tests for Snooze logic.
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
# Snooze Feature Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### 1. Metadata Storage (Account Sync)
|
||||||
|
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
|
||||||
|
- **JMAP:** Use `keywords`.
|
||||||
|
- **IMAP:** Use User Flags (keywords).
|
||||||
|
|
||||||
|
### 2. Database Changes
|
||||||
|
- **Migration v22:**
|
||||||
|
- `Emails` table:
|
||||||
|
- `snoozedUntil` (DateTime, nullable)
|
||||||
|
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
|
||||||
|
- Index on `snoozedUntil`.
|
||||||
|
|
||||||
|
### 3. Repository Updates (`EmailRepository`)
|
||||||
|
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
|
||||||
|
- Optimistically update local DB.
|
||||||
|
- Enqueue `snooze` change.
|
||||||
|
- New method: `Future<int> wakeUpEmails(String accountId)`
|
||||||
|
- Find local rows where `snoozedUntil <= now`.
|
||||||
|
- Enqueue `move` back to original mailbox.
|
||||||
|
- Clear snooze metadata.
|
||||||
|
|
||||||
|
### 4. Sync Loop Integration
|
||||||
|
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
|
||||||
|
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
|
||||||
|
|
||||||
|
### 5. UI Implementation
|
||||||
|
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
|
||||||
|
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
|
||||||
|
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
1. [ ] Database migration and model updates.
|
||||||
|
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
|
||||||
|
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
|
||||||
|
4. [ ] Update sync logic to parse snooze keywords.
|
||||||
|
5. [ ] Integrate `wakeUpEmails` into the sync loop.
|
||||||
|
6. [ ] UI: Snooze picker dialog.
|
||||||
|
7. [ ] UI: Add Snooze action to list and detail screens.
|
||||||
|
8. [ ] Testing and validation.
|
||||||
@@ -19,6 +19,8 @@ class Email {
|
|||||||
final String? inReplyTo;
|
final String? inReplyTo;
|
||||||
// Space-separated RFC 2822 References header value.
|
// Space-separated RFC 2822 References header value.
|
||||||
final String? references;
|
final String? references;
|
||||||
|
final DateTime? snoozedUntil;
|
||||||
|
final String? snoozedFromMailboxPath;
|
||||||
|
|
||||||
const Email({
|
const Email({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -39,6 +41,8 @@ class Email {
|
|||||||
this.messageId,
|
this.messageId,
|
||||||
this.inReplyTo,
|
this.inReplyTo,
|
||||||
this.references,
|
this.references,
|
||||||
|
this.snoozedUntil,
|
||||||
|
this.snoozedFromMailboxPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Email.fromJson(Map<String, dynamic> json) {
|
factory Email.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -69,6 +73,10 @@ class Email {
|
|||||||
messageId: json['messageId'] as String?,
|
messageId: json['messageId'] as String?,
|
||||||
inReplyTo: json['inReplyTo'] as String?,
|
inReplyTo: json['inReplyTo'] as String?,
|
||||||
references: json['references'] as String?,
|
references: json['references'] as String?,
|
||||||
|
snoozedUntil: json['snoozedUntil'] != null
|
||||||
|
? DateTime.parse(json['snoozedUntil'] as String)
|
||||||
|
: null,
|
||||||
|
snoozedFromMailboxPath: json['snoozedFromMailboxPath'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +100,8 @@ class Email {
|
|||||||
'messageId': messageId,
|
'messageId': messageId,
|
||||||
'inReplyTo': inReplyTo,
|
'inReplyTo': inReplyTo,
|
||||||
'references': references,
|
'references': references,
|
||||||
|
'snoozedUntil': snoozedUntil?.toIso8601String(),
|
||||||
|
'snoozedFromMailboxPath': snoozedFromMailboxPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +124,8 @@ class Email {
|
|||||||
String? messageId,
|
String? messageId,
|
||||||
String? inReplyTo,
|
String? inReplyTo,
|
||||||
String? references,
|
String? references,
|
||||||
|
DateTime? snoozedUntil,
|
||||||
|
String? snoozedFromMailboxPath,
|
||||||
}) {
|
}) {
|
||||||
return Email(
|
return Email(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -134,6 +146,9 @@ class Email {
|
|||||||
messageId: messageId ?? this.messageId,
|
messageId: messageId ?? this.messageId,
|
||||||
inReplyTo: inReplyTo ?? this.inReplyTo,
|
inReplyTo: inReplyTo ?? this.inReplyTo,
|
||||||
references: references ?? this.references,
|
references: references ?? this.references,
|
||||||
|
snoozedUntil: snoozedUntil ?? this.snoozedUntil,
|
||||||
|
snoozedFromMailboxPath:
|
||||||
|
snoozedFromMailboxPath ?? this.snoozedFromMailboxPath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ abstract class EmailRepository {
|
|||||||
/// queue. Returns true if a pending change was found and removed.
|
/// queue. Returns true if a pending change was found and removed.
|
||||||
Future<bool> cancelPendingChange(String emailId, String changeType);
|
Future<bool> cancelPendingChange(String emailId, String changeType);
|
||||||
|
|
||||||
|
/// Snoozes the email until [until]. It will be moved to a "Snoozed" mailbox.
|
||||||
|
Future<void> snoozeEmail(String emailId, DateTime until);
|
||||||
|
|
||||||
|
/// Checks for expired snoozes and moves them back to their original mailbox.
|
||||||
|
/// Returns the number of emails woken up.
|
||||||
|
Future<int> wakeUpEmails(String accountId);
|
||||||
|
|
||||||
/// Restores previously deleted/moved emails to the local database.
|
/// Restores previously deleted/moved emails to the local database.
|
||||||
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
||||||
Future<void> restoreEmails(List<Email> emails);
|
Future<void> restoreEmails(List<Email> emails);
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ class _AccountSync implements _SyncLoop {
|
|||||||
|
|
||||||
Future<_SyncStats> _sync() async {
|
Future<_SyncStats> _sync() async {
|
||||||
final password = await _accounts.getPassword(account.id);
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
// Check for expired snoozes and move them back to Inbox before syncing.
|
||||||
|
await _emails.wakeUpEmails(account.id);
|
||||||
|
|
||||||
final pendingFlushed =
|
final pendingFlushed =
|
||||||
await _emails.flushPendingChanges(account.id, password);
|
await _emails.flushPendingChanges(account.id, password);
|
||||||
final mailboxesSynced = await _mailboxes.syncMailboxes(account.id);
|
final mailboxesSynced = await _mailboxes.syncMailboxes(account.id);
|
||||||
@@ -448,6 +452,9 @@ class _JmapAccountSync implements _SyncLoop {
|
|||||||
Future<_SyncStats> _sync() async {
|
Future<_SyncStats> _sync() async {
|
||||||
final password = await _accounts.getPassword(account.id);
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
// Check for expired snoozes and move them back to Inbox before syncing.
|
||||||
|
await _emails.wakeUpEmails(account.id);
|
||||||
|
|
||||||
// Drain outbound queue before pulling from server.
|
// Drain outbound queue before pulling from server.
|
||||||
final pendingFlushed =
|
final pendingFlushed =
|
||||||
await _emails.flushPendingChanges(account.id, password);
|
await _emails.flushPendingChanges(account.id, password);
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ class Emails extends Table {
|
|||||||
// Space-separated list of Message-IDs (RFC 2822 References header).
|
// Space-separated list of Message-IDs (RFC 2822 References header).
|
||||||
TextColumn get references => text().nullable()();
|
TextColumn get references => text().nullable()();
|
||||||
|
|
||||||
|
// Added in schema v22:
|
||||||
|
DateTimeColumn get snoozedUntil => dateTime().nullable()();
|
||||||
|
TextColumn get snoozedFromMailboxPath => text().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -260,7 +264,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 21;
|
int get schemaVersion => 22;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -392,6 +396,16 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 21) {
|
if (from < 21) {
|
||||||
await m.createTable(undoActions);
|
await m.createTable(undoActions);
|
||||||
}
|
}
|
||||||
|
if (from < 22) {
|
||||||
|
await m.addColumn(emails, emails.snoozedUntil);
|
||||||
|
await m.addColumn(emails, emails.snoozedFromMailboxPath);
|
||||||
|
await m.createIndex(
|
||||||
|
Index(
|
||||||
|
'emails_snoozed_until',
|
||||||
|
'CREATE INDEX emails_snoozed_until ON emails (accountId, snoozedUntil) WHERE snoozedUntil IS NOT NULL;',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,6 +560,21 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
) ??
|
) ??
|
||||||
emailId;
|
emailId;
|
||||||
affectedThreads.add(threadId);
|
affectedThreads.add(threadId);
|
||||||
|
|
||||||
|
DateTime? snoozedUntil;
|
||||||
|
for (final String flag in msg.flags ?? <String>[]) {
|
||||||
|
if (flag.startsWith('snz:')) {
|
||||||
|
final ts = flag.substring(4);
|
||||||
|
// Format: YYYYMMDDTHHMMSSZ (no dashes/colons)
|
||||||
|
if (ts.length >= 15) {
|
||||||
|
final formatted =
|
||||||
|
'${ts.substring(0, 4)}-${ts.substring(4, 6)}-${ts.substring(6, 8)}T${ts.substring(9, 11)}:${ts.substring(11, 13)}:${ts.substring(13, 15)}Z';
|
||||||
|
snoozedUntil = DateTime.tryParse(formatted);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _db.into(_db.emails).insertOnConflictUpdate(
|
await _db.into(_db.emails).insertOnConflictUpdate(
|
||||||
EmailsCompanion.insert(
|
EmailsCompanion.insert(
|
||||||
id: emailId,
|
id: emailId,
|
||||||
@@ -579,6 +594,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
messageId: Value(msgId),
|
messageId: Value(msgId),
|
||||||
inReplyTo: Value(inReplyTo),
|
inReplyTo: Value(inReplyTo),
|
||||||
references: Value(refs),
|
references: Value(refs),
|
||||||
|
snoozedUntil: Value(snoozedUntil),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1080,9 +1096,22 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final mailboxPath = mailboxIds?.keys.firstOrNull ?? '';
|
final mailboxPath = mailboxIds?.keys.firstOrNull ?? '';
|
||||||
|
|
||||||
final keywords = m['keywords'] as Map<String, dynamic>? ?? {};
|
final keywords = m['keywords'] as Map<String, dynamic>? ?? {};
|
||||||
final from = _encodeJmapAddresses(m['from']);
|
DateTime? snoozedUntil;
|
||||||
final to = _encodeJmapAddresses(m['to']);
|
for (final String k in keywords.keys) {
|
||||||
final cc = _encodeJmapAddresses(m['cc']);
|
if (k.startsWith('snz:')) {
|
||||||
|
final ts = k.substring(4);
|
||||||
|
if (ts.length >= 15) {
|
||||||
|
final formatted =
|
||||||
|
'${ts.substring(0, 4)}-${ts.substring(4, 6)}-${ts.substring(6, 8)}T${ts.substring(9, 11)}:${ts.substring(11, 13)}:${ts.substring(13, 15)}Z';
|
||||||
|
snoozedUntil = DateTime.tryParse(formatted);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final from = _encodeJmapAddresses(m['from'] as List<dynamic>?);
|
||||||
|
final to = _encodeJmapAddresses(m['to'] as List<dynamic>?);
|
||||||
|
final cc = _encodeJmapAddresses(m['cc'] as List<dynamic>?);
|
||||||
final sentAt = _parseDate(m['sentAt'] as String?);
|
final sentAt = _parseDate(m['sentAt'] as String?);
|
||||||
final receivedAt =
|
final receivedAt =
|
||||||
_parseDate(m['receivedAt'] as String?) ?? DateTime.now();
|
_parseDate(m['receivedAt'] as String?) ?? DateTime.now();
|
||||||
@@ -1118,6 +1147,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
messageId: Value(jmapMessageId),
|
messageId: Value(jmapMessageId),
|
||||||
inReplyTo: Value(jmapInReplyTo),
|
inReplyTo: Value(jmapInReplyTo),
|
||||||
references: Value(jmapReferences),
|
references: Value(jmapReferences),
|
||||||
|
snoozedUntil: Value(snoozedUntil),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1611,6 +1641,113 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> snoozeEmail(String emailId, DateTime until) async {
|
||||||
|
final row = await (_db.select(_db.emails)
|
||||||
|
..where((t) => t.id.equals(emailId)))
|
||||||
|
.getSingle();
|
||||||
|
final account = (await _accounts.getAccount(row.accountId))!;
|
||||||
|
|
||||||
|
// Find or create Snoozed mailbox.
|
||||||
|
var snoozedMailbox = await (_db.select(_db.mailboxes)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(account.id) & t.role.equals('snoozed'),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
snoozedMailbox ??= await (_db.select(_db.mailboxes)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(account.id) & t.name.equals('Snoozed'),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
// Default path if not found; flush logic will attempt to create it.
|
||||||
|
final destPath = snoozedMailbox?.path ?? 'Snoozed';
|
||||||
|
|
||||||
|
// Optimistic local update.
|
||||||
|
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
||||||
|
EmailsCompanion(
|
||||||
|
mailboxPath: Value(destPath),
|
||||||
|
snoozedUntil: Value(until),
|
||||||
|
snoozedFromMailboxPath: Value(row.mailboxPath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _enqueueChange(
|
||||||
|
account.id,
|
||||||
|
emailId,
|
||||||
|
'snooze',
|
||||||
|
jsonEncode({
|
||||||
|
'uid': row.uid,
|
||||||
|
'src': row.mailboxPath,
|
||||||
|
'dest': destPath,
|
||||||
|
'until': until.toIso8601String(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
row.mailboxPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
|
await _updateThread(
|
||||||
|
row.accountId,
|
||||||
|
destPath,
|
||||||
|
row.threadId ?? emailId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> wakeUpEmails(String accountId) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final expired = await (_db.select(_db.emails)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) &
|
||||||
|
t.snoozedUntil.isSmallerOrEqualValue(now),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (expired.isEmpty) return 0;
|
||||||
|
|
||||||
|
for (final row in expired) {
|
||||||
|
// Per instructions: "get to inbox moved by app".
|
||||||
|
final inbox = await (_db.select(_db.mailboxes)
|
||||||
|
..where(
|
||||||
|
(t) => t.accountId.equals(accountId) & t.role.equals('inbox'),
|
||||||
|
)
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
final dest = inbox?.path ?? 'INBOX';
|
||||||
|
|
||||||
|
await _enqueueChange(
|
||||||
|
accountId,
|
||||||
|
row.id,
|
||||||
|
'unsnooze',
|
||||||
|
jsonEncode({
|
||||||
|
'uid': row.uid,
|
||||||
|
'src': row.mailboxPath,
|
||||||
|
'dest': dest,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optimistic local update.
|
||||||
|
await (_db.update(_db.emails)..where((t) => t.id.equals(row.id))).write(
|
||||||
|
EmailsCompanion(
|
||||||
|
mailboxPath: Value(dest),
|
||||||
|
snoozedUntil: const Value(null),
|
||||||
|
snoozedFromMailboxPath: const Value(null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _updateThread(accountId, row.mailboxPath, row.threadId ?? row.id);
|
||||||
|
await _updateThread(accountId, dest, row.threadId ?? row.id);
|
||||||
|
}
|
||||||
|
return expired.length;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> restoreEmails(List<model.Email> emails) async {
|
Future<void> restoreEmails(List<model.Email> emails) async {
|
||||||
for (final e in emails) {
|
for (final e in emails) {
|
||||||
@@ -1779,6 +1916,37 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
case 'delete':
|
case 'delete':
|
||||||
await client.uidMarkDeleted(seq);
|
await client.uidMarkDeleted(seq);
|
||||||
await client.uidExpunge(seq);
|
await client.uidExpunge(seq);
|
||||||
|
case 'snooze':
|
||||||
|
final until = payload['until'] as String;
|
||||||
|
// ISO8601 with colons is fine for IMAP atoms, but we use a cleaner
|
||||||
|
// format just in case.
|
||||||
|
final timestamp = until.replaceAll(':', '').replaceAll('-', '');
|
||||||
|
final keyword = 'snz:$timestamp';
|
||||||
|
final dest = payload['dest'] as String;
|
||||||
|
try {
|
||||||
|
await client.createMailbox(dest);
|
||||||
|
} catch (_) {}
|
||||||
|
await client.uidStore(seq, [keyword], action: imap.StoreAction.add);
|
||||||
|
await client.uidMove(seq, targetMailboxPath: dest);
|
||||||
|
case 'unsnooze':
|
||||||
|
final dest = payload['dest'] as String;
|
||||||
|
try {
|
||||||
|
await client.createMailbox(dest);
|
||||||
|
} catch (_) {}
|
||||||
|
// Remove any existing snooze flags.
|
||||||
|
final fetch = await client.uidFetchMessages(seq, 'FLAGS');
|
||||||
|
if (fetch.messages.isNotEmpty) {
|
||||||
|
final flags = fetch.messages.first.flags ?? [];
|
||||||
|
final snzFlags = flags.where((f) => f.startsWith('snz:')).toList();
|
||||||
|
if (snzFlags.isNotEmpty) {
|
||||||
|
await client.uidStore(
|
||||||
|
seq,
|
||||||
|
snzFlags,
|
||||||
|
action: imap.StoreAction.remove,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.uidMove(seq, targetMailboxPath: dest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1865,6 +2033,68 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
case 'snooze':
|
||||||
|
final until = payload['until'] as String;
|
||||||
|
final timestamp = until.replaceAll(':', '').replaceAll('-', '');
|
||||||
|
final keyword = 'snz:$timestamp';
|
||||||
|
final destMailboxId = payload['dest'] as String;
|
||||||
|
final srcMailboxId = payload['src'] as String;
|
||||||
|
responses = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
setArgs({
|
||||||
|
'update': {
|
||||||
|
jmapEmailId: {
|
||||||
|
'keywords/$keyword': true,
|
||||||
|
'mailboxIds/$destMailboxId': true,
|
||||||
|
'mailboxIds/$srcMailboxId': null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'0',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
case 'unsnooze':
|
||||||
|
final destMailboxId = payload['dest'] as String;
|
||||||
|
final srcMailboxId = payload['src'] as String;
|
||||||
|
// Fetch current keywords to identify which snz: keywords to remove.
|
||||||
|
final getResponses = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'ids': [jmapEmailId],
|
||||||
|
'properties': ['keywords'],
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
final getResult = _responseArgs(getResponses, 0, 'Email/get');
|
||||||
|
final email = (getResult['list'] as List).firstOrNull as Map?;
|
||||||
|
final keywords = (email?['keywords'] as Map?) ?? {};
|
||||||
|
final toRemove = keywords.keys.where(
|
||||||
|
(k) => k.toString().startsWith('snz:'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final update = {
|
||||||
|
'mailboxIds/$destMailboxId': true,
|
||||||
|
'mailboxIds/$srcMailboxId': null,
|
||||||
|
};
|
||||||
|
for (final k in toRemove) {
|
||||||
|
update['keywords/$k'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
responses = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
setArgs({
|
||||||
|
'update': {jmapEmailId: update},
|
||||||
|
}),
|
||||||
|
'0',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -2383,6 +2613,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
messageId: row.messageId,
|
messageId: row.messageId,
|
||||||
inReplyTo: row.inReplyTo,
|
inReplyTo: row.inReplyTo,
|
||||||
references: row.references,
|
references: row.references,
|
||||||
|
snoozedUntil: row.snoozedUntil,
|
||||||
|
snoozedFromMailboxPath: row.snoozedFromMailboxPath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:sharedinbox/core/models/undo_action.dart';
|
|||||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
|
|
||||||
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
||||||
|
|
||||||
@@ -109,6 +110,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed:
|
onPressed:
|
||||||
header == null ? null : () => _moveTo(context, header),
|
header == null ? null : () => _moveTo(context, header),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
tooltip: 'Snooze',
|
||||||
|
onPressed:
|
||||||
|
header == null ? null : () => _snooze(context, header),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
@@ -386,6 +393,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (context.mounted) context.pop();
|
if (context.mounted) context.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _snooze(BuildContext context, Email header) async {
|
||||||
|
final until = await showModalBottomSheet<DateTime>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => const SnoozePicker(),
|
||||||
|
);
|
||||||
|
if (until == null || !context.mounted) return;
|
||||||
|
|
||||||
|
await ref.read(emailRepositoryProvider).snoozeEmail(widget.emailId, until);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Snoozed until ${DateFormat('MMM d, HH:mm').format(until)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showHeaders(BuildContext context, EmailBody body) {
|
void _showHeaders(BuildContext context, EmailBody body) {
|
||||||
if (body.headers.isEmpty) {
|
if (body.headers.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/models/undo_action.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
|
|
||||||
final _dateFmt = DateFormat('MMM d');
|
final _dateFmt = DateFormat('MMM d');
|
||||||
|
|
||||||
@@ -243,6 +244,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
tooltip: 'Move to folder',
|
tooltip: 'Move to folder',
|
||||||
onPressed: _batchMove,
|
onPressed: _batchMove,
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
tooltip: 'Snooze',
|
||||||
|
onPressed: _batchSnooze,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -445,6 +451,31 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
ref.read(undoServiceProvider.notifier).pushAction(action);
|
ref.read(undoServiceProvider.notifier).pushAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _batchSnooze() async {
|
||||||
|
final ids = _selectedEmailIds;
|
||||||
|
final until = await showModalBottomSheet<DateTime>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => const SnoozePicker(),
|
||||||
|
);
|
||||||
|
if (until == null || !mounted) return;
|
||||||
|
|
||||||
|
_clearSelection();
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
for (final id in ids) {
|
||||||
|
await repo.snoozeEmail(id, until);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildThreadList(List<EmailThread> threads) {
|
Widget _buildThreadList(List<EmailThread> threads) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: threads.length,
|
itemCount: threads.length,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class SnoozePicker extends StatelessWidget {
|
||||||
|
const SnoozePicker({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final tomorrow = DateTime(now.year, now.month, now.day + 1, 8);
|
||||||
|
final thisEvening = DateTime(now.year, now.month, now.day, 18);
|
||||||
|
final nextWeek = now.add(const Duration(days: 7));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Snooze until…',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (now.hour < 18)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.wb_sunny_outlined),
|
||||||
|
title: const Text('This evening'),
|
||||||
|
trailing: Text(DateFormat('HH:mm').format(thisEvening)),
|
||||||
|
onTap: () => Navigator.pop(context, thisEvening),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.wb_twilight),
|
||||||
|
title: const Text('Tomorrow morning'),
|
||||||
|
trailing: Text(DateFormat('EEE, 08:00').format(tomorrow)),
|
||||||
|
onTap: () => Navigator.pop(context, tomorrow),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.next_week_outlined),
|
||||||
|
title: const Text('Next week'),
|
||||||
|
trailing: Text(DateFormat('MMM d, 08:00').format(nextWeek)),
|
||||||
|
onTap: () => Navigator.pop(context, nextWeek),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.calendar_month_outlined),
|
||||||
|
title: const Text('Pick date & time…'),
|
||||||
|
onTap: () async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: now,
|
||||||
|
firstDate: now,
|
||||||
|
lastDate: now.add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (date == null) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final time = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: const TimeOfDay(hour: 8, minute: 0),
|
||||||
|
);
|
||||||
|
if (time == null) return;
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
final chosen = DateTime(
|
||||||
|
date.year,
|
||||||
|
date.month,
|
||||||
|
date.day,
|
||||||
|
time.hour,
|
||||||
|
time.minute,
|
||||||
|
);
|
||||||
|
Navigator.pop(context, chosen);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,12 @@ class _FakeEmails implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<bool> cancelPendingChange(String id, String type) async => false;
|
Future<bool> cancelPendingChange(String id, String type) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> snoozeEmail(String emailId, DateTime until) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> wakeUpEmails(String accountId) async => 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> restoreEmails(List<Email> emails) async {}
|
Future<void> restoreEmails(List<Email> emails) async {}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<bool> cancelPendingChange(String id, String type) async => false;
|
Future<bool> cancelPendingChange(String id, String type) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> snoozeEmail(String emailId, DateTime until) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> wakeUpEmails(String accountId) async => 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> restoreEmails(List<Email> emails) async {}
|
Future<void> restoreEmails(List<Email> emails) async {}
|
||||||
|
|
||||||
|
|||||||
@@ -504,6 +504,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
|||||||
returnValue: _i4.Future<bool>.value(false),
|
returnValue: _i4.Future<bool>.value(false),
|
||||||
) as _i4.Future<bool>);
|
) as _i4.Future<bool>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> snoozeEmail(
|
||||||
|
String? emailId,
|
||||||
|
DateTime? until,
|
||||||
|
) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#snoozeEmail,
|
||||||
|
[
|
||||||
|
emailId,
|
||||||
|
until,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#wakeUpEmails,
|
||||||
|
[accountId],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<int>.value(0),
|
||||||
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
|
|||||||
@@ -614,6 +614,74 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('Snooze', () {
|
||||||
|
test('snoozeEmail enqueues snooze change and updates local DB', () async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:5',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 5,
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final until = DateTime(2026, 5, 10, 15);
|
||||||
|
await r.emails.snoozeEmail('acc-1:5', until);
|
||||||
|
|
||||||
|
final email = await r.emails.getEmail('acc-1:5');
|
||||||
|
expect(email!.snoozedUntil, until);
|
||||||
|
expect(email.mailboxPath, 'Snoozed');
|
||||||
|
expect(email.snoozedFromMailboxPath, 'INBOX');
|
||||||
|
|
||||||
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
|
expect(changes, hasLength(1));
|
||||||
|
expect(changes.first.changeType, 'snooze');
|
||||||
|
expect(changes.first.payload, contains('2026-05-10T15:00:00.000'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wakeUpEmails enqueues unsnooze for expired emails', () async {
|
||||||
|
final r = _makeRepos();
|
||||||
|
await r.accounts.addAccount(_account, 'pw');
|
||||||
|
// Seed Inbox mailbox
|
||||||
|
await r.db.into(r.db.mailboxes).insert(
|
||||||
|
MailboxesCompanion.insert(
|
||||||
|
id: 'acc-1:INBOX',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
path: 'INBOX',
|
||||||
|
name: 'Inbox',
|
||||||
|
role: const Value('inbox'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final past = DateTime.now().subtract(const Duration(hours: 1));
|
||||||
|
await r.db.into(r.db.emails).insert(
|
||||||
|
EmailsCompanion.insert(
|
||||||
|
id: 'acc-1:5',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'Snoozed',
|
||||||
|
uid: 5,
|
||||||
|
receivedAt: DateTime(2024),
|
||||||
|
snoozedUntil: Value(past),
|
||||||
|
snoozedFromMailboxPath: const Value('INBOX'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final count = await r.emails.wakeUpEmails('acc-1');
|
||||||
|
expect(count, 1);
|
||||||
|
|
||||||
|
final email = await r.emails.getEmail('acc-1:5');
|
||||||
|
expect(email!.snoozedUntil, isNull);
|
||||||
|
expect(email.mailboxPath, 'INBOX');
|
||||||
|
|
||||||
|
final changes = await r.db.select(r.db.pendingChanges).get();
|
||||||
|
expect(changes, hasLength(1));
|
||||||
|
expect(changes.first.changeType, 'unsnooze');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('JMAP getEmailBody', () {
|
group('JMAP getEmailBody', () {
|
||||||
http.Client mockBodyClient({
|
http.Client mockBodyClient({
|
||||||
String text = 'Hello from JMAP',
|
String text = 'Hello from JMAP',
|
||||||
|
|||||||
@@ -374,6 +374,32 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
|||||||
returnValue: _i4.Future<bool>.value(false),
|
returnValue: _i4.Future<bool>.value(false),
|
||||||
) as _i4.Future<bool>);
|
) as _i4.Future<bool>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<void> snoozeEmail(
|
||||||
|
String? emailId,
|
||||||
|
DateTime? until,
|
||||||
|
) =>
|
||||||
|
(super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#snoozeEmail,
|
||||||
|
[
|
||||||
|
emailId,
|
||||||
|
until,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<void>.value(),
|
||||||
|
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||||
|
) as _i4.Future<void>);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
|
||||||
|
Invocation.method(
|
||||||
|
#wakeUpEmails,
|
||||||
|
[accountId],
|
||||||
|
),
|
||||||
|
returnValue: _i4.Future<int>.value(0),
|
||||||
|
) as _i4.Future<int>);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
||||||
(super.noSuchMethod(
|
(super.noSuchMethod(
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
@override
|
@override
|
||||||
Future<bool> cancelPendingChange(String id, String type) async => false;
|
Future<bool> cancelPendingChange(String id, String type) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> snoozeEmail(String emailId, DateTime until) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> wakeUpEmails(String accountId) async => 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> restoreEmails(List<Email> emails) async {}
|
Future<void> restoreEmails(List<Email> emails) async {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user