diff --git a/PLAN_SNOOZE.md b/PLAN_SNOOZE.md new file mode 100644 index 0000000..60e66c4 --- /dev/null +++ b/PLAN_SNOOZE.md @@ -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:` (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 snoozeEmail(String emailId, DateTime until)` + - Optimistically update local DB. + - Enqueue `snooze` change. +- New method: `Future 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. diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index f915247..1574778 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -19,6 +19,8 @@ class Email { final String? inReplyTo; // Space-separated RFC 2822 References header value. final String? references; + final DateTime? snoozedUntil; + final String? snoozedFromMailboxPath; const Email({ required this.id, @@ -39,6 +41,8 @@ class Email { this.messageId, this.inReplyTo, this.references, + this.snoozedUntil, + this.snoozedFromMailboxPath, }); factory Email.fromJson(Map json) { @@ -69,6 +73,10 @@ class Email { messageId: json['messageId'] as String?, inReplyTo: json['inReplyTo'] 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, 'inReplyTo': inReplyTo, 'references': references, + 'snoozedUntil': snoozedUntil?.toIso8601String(), + 'snoozedFromMailboxPath': snoozedFromMailboxPath, }; } @@ -114,6 +124,8 @@ class Email { String? messageId, String? inReplyTo, String? references, + DateTime? snoozedUntil, + String? snoozedFromMailboxPath, }) { return Email( id: id ?? this.id, @@ -134,6 +146,9 @@ class Email { messageId: messageId ?? this.messageId, inReplyTo: inReplyTo ?? this.inReplyTo, references: references ?? this.references, + snoozedUntil: snoozedUntil ?? this.snoozedUntil, + snoozedFromMailboxPath: + snoozedFromMailboxPath ?? this.snoozedFromMailboxPath, ); } } diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 00daea5..a1f0f6b 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -74,6 +74,13 @@ abstract class EmailRepository { /// queue. Returns true if a pending change was found and removed. Future cancelPendingChange(String emailId, String changeType); + /// Snoozes the email until [until]. It will be moved to a "Snoozed" mailbox. + Future 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 wakeUpEmails(String accountId); + /// Restores previously deleted/moved emails to the local database. /// Used for the "Undo" feature when the original rows were hard-deleted (IMAP). Future restoreEmails(List emails); diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index eeb9f7a..6829c91 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -235,6 +235,10 @@ class _AccountSync implements _SyncLoop { Future<_SyncStats> _sync() async { 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 = await _emails.flushPendingChanges(account.id, password); final mailboxesSynced = await _mailboxes.syncMailboxes(account.id); @@ -448,6 +452,9 @@ class _JmapAccountSync implements _SyncLoop { Future<_SyncStats> _sync() async { 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. final pendingFlushed = await _emails.flushPendingChanges(account.id, password); diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 028d6f2..b25ced4 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -84,6 +84,10 @@ class Emails extends Table { // Space-separated list of Message-IDs (RFC 2822 References header). TextColumn get references => text().nullable()(); + // Added in schema v22: + DateTimeColumn get snoozedUntil => dateTime().nullable()(); + TextColumn get snoozedFromMailboxPath => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -260,7 +264,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 21; + int get schemaVersion => 22; @override MigrationStrategy get migration => MigrationStrategy( @@ -392,6 +396,16 @@ class AppDatabase extends _$AppDatabase { if (from < 21) { 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;', + ), + ); + } }, ); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 9550b3c..43df2d3 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -560,6 +560,21 @@ class EmailRepositoryImpl implements EmailRepository { ) ?? emailId; affectedThreads.add(threadId); + + DateTime? snoozedUntil; + for (final String flag in msg.flags ?? []) { + 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( EmailsCompanion.insert( id: emailId, @@ -579,6 +594,7 @@ class EmailRepositoryImpl implements EmailRepository { messageId: Value(msgId), inReplyTo: Value(inReplyTo), references: Value(refs), + snoozedUntil: Value(snoozedUntil), ), ); } @@ -1080,9 +1096,22 @@ class EmailRepositoryImpl implements EmailRepository { final mailboxPath = mailboxIds?.keys.firstOrNull ?? ''; final keywords = m['keywords'] as Map? ?? {}; - final from = _encodeJmapAddresses(m['from']); - final to = _encodeJmapAddresses(m['to']); - final cc = _encodeJmapAddresses(m['cc']); + DateTime? snoozedUntil; + for (final String k in keywords.keys) { + 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?); + final to = _encodeJmapAddresses(m['to'] as List?); + final cc = _encodeJmapAddresses(m['cc'] as List?); final sentAt = _parseDate(m['sentAt'] as String?); final receivedAt = _parseDate(m['receivedAt'] as String?) ?? DateTime.now(); @@ -1118,6 +1147,7 @@ class EmailRepositoryImpl implements EmailRepository { messageId: Value(jmapMessageId), inReplyTo: Value(jmapInReplyTo), references: Value(jmapReferences), + snoozedUntil: Value(snoozedUntil), ), ); @@ -1611,6 +1641,113 @@ class EmailRepositoryImpl implements EmailRepository { return false; } + @override + Future 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 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 Future restoreEmails(List emails) async { for (final e in emails) { @@ -1779,6 +1916,37 @@ class EmailRepositoryImpl implements EmailRepository { case 'delete': await client.uidMarkDeleted(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: return null; } @@ -2383,6 +2613,8 @@ class EmailRepositoryImpl implements EmailRepository { messageId: row.messageId, inReplyTo: row.inReplyTo, references: row.references, + snoozedUntil: row.snoozedUntil, + snoozedFromMailboxPath: row.snoozedFromMailboxPath, ); } diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 771700d..3ea7f92 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -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/html_utils.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm'); @@ -109,6 +110,12 @@ class _EmailDetailScreenState extends ConsumerState { onPressed: header == null ? null : () => _moveTo(context, header), ), + IconButton( + icon: const Icon(Icons.access_time), + tooltip: 'Snooze', + onPressed: + header == null ? null : () => _snooze(context, header), + ), IconButton( icon: const Icon(Icons.delete), tooltip: 'Delete', @@ -386,6 +393,27 @@ class _EmailDetailScreenState extends ConsumerState { if (context.mounted) context.pop(); } + Future _snooze(BuildContext context, Email header) async { + final until = await showModalBottomSheet( + 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) { if (body.headers.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 8b7cc8c..48f8376 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -11,6 +11,7 @@ import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; +import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; final _dateFmt = DateFormat('MMM d'); @@ -243,6 +244,11 @@ class _EmailListScreenState extends ConsumerState { tooltip: 'Move to folder', onPressed: _batchMove, ), + IconButton( + icon: const Icon(Icons.access_time), + tooltip: 'Snooze', + onPressed: _batchSnooze, + ), ], ), ); @@ -445,6 +451,31 @@ class _EmailListScreenState extends ConsumerState { ref.read(undoServiceProvider.notifier).pushAction(action); } + Future _batchSnooze() async { + final ids = _selectedEmailIds; + final until = await showModalBottomSheet( + 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 threads) { return ListView.builder( itemCount: threads.length, diff --git a/lib/ui/widgets/snooze_picker.dart b/lib/ui/widgets/snooze_picker.dart new file mode 100644 index 0000000..966306a --- /dev/null +++ b/lib/ui/widgets/snooze_picker.dart @@ -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); + } + }, + ), + ], + ); + } +} diff --git a/test/integration/account_sync_manager_test.dart b/test/integration/account_sync_manager_test.dart index 8cf4a88..6ced071 100644 --- a/test/integration/account_sync_manager_test.dart +++ b/test/integration/account_sync_manager_test.dart @@ -134,6 +134,12 @@ class _FakeEmails implements EmailRepository { @override Future cancelPendingChange(String id, String type) async => false; + @override + Future snoozeEmail(String emailId, DateTime until) async {} + + @override + Future wakeUpEmails(String accountId) async => 0; + @override Future restoreEmails(List emails) async {} diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 3995ce8..84f5b50 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -57,6 +57,12 @@ class FakeEmailRepository implements EmailRepository { @override Future cancelPendingChange(String id, String type) async => false; + @override + Future snoozeEmail(String emailId, DateTime until) async {} + + @override + Future wakeUpEmails(String accountId) async => 0; + @override Future restoreEmails(List emails) async {} diff --git a/test/unit/account_sync_manager_test.mocks.dart b/test/unit/account_sync_manager_test.mocks.dart index bfc7500..36784ed 100644 --- a/test/unit/account_sync_manager_test.mocks.dart +++ b/test/unit/account_sync_manager_test.mocks.dart @@ -504,6 +504,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { returnValue: _i4.Future.value(false), ) as _i4.Future); + @override + _i4.Future snoozeEmail( + String? emailId, + DateTime? until, + ) => + (super.noSuchMethod( + Invocation.method( + #snoozeEmail, + [ + emailId, + until, + ], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future wakeUpEmails(String? accountId) => (super.noSuchMethod( + Invocation.method( + #wakeUpEmails, + [accountId], + ), + returnValue: _i4.Future.value(0), + ) as _i4.Future); + @override _i4.Future restoreEmails(List<_i2.Email>? emails) => (super.noSuchMethod( diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index 9be375a..50c8bbe 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -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', () { http.Client mockBodyClient({ String text = 'Hello from JMAP', diff --git a/test/unit/undo_service_test.mocks.dart b/test/unit/undo_service_test.mocks.dart index 88c21ba..b7341dd 100644 --- a/test/unit/undo_service_test.mocks.dart +++ b/test/unit/undo_service_test.mocks.dart @@ -374,6 +374,32 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository { returnValue: _i4.Future.value(false), ) as _i4.Future); + @override + _i4.Future snoozeEmail( + String? emailId, + DateTime? until, + ) => + (super.noSuchMethod( + Invocation.method( + #snoozeEmail, + [ + emailId, + until, + ], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + + @override + _i4.Future wakeUpEmails(String? accountId) => (super.noSuchMethod( + Invocation.method( + #wakeUpEmails, + [accountId], + ), + returnValue: _i4.Future.value(0), + ) as _i4.Future); + @override _i4.Future restoreEmails(List<_i2.Email>? emails) => (super.noSuchMethod( diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 8b17a4d..909b258 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -209,6 +209,12 @@ class FakeEmailRepository implements EmailRepository { @override Future cancelPendingChange(String id, String type) async => false; + @override + Future snoozeEmail(String emailId, DateTime until) async {} + + @override + Future wakeUpEmails(String accountId) async => 0; + @override Future restoreEmails(List emails) async {}