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:
Thomas SharedInbox
2026-05-10 21:50:13 +02:00
parent 0855049c30
commit b7ff02711b
15 changed files with 598 additions and 4 deletions
+46
View File
@@ -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.
+15
View File
@@ -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);
+7
View File
@@ -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);
+15 -1
View File
@@ -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,
); );
} }
+28
View File
@@ -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(
+31
View File
@@ -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,
+76
View File
@@ -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 {}
+6
View File
@@ -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(
+68
View File
@@ -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',
+26
View File
@@ -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(
+6
View File
@@ -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 {}