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;
|
||||
// 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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ abstract class EmailRepository {
|
||||
/// queue. Returns true if a pending change was found and removed.
|
||||
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.
|
||||
/// Used for the "Undo" feature when the original rows were hard-deleted (IMAP).
|
||||
Future<void> restoreEmails(List<Email> emails);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Column> 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;',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -560,6 +560,21 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
) ??
|
||||
emailId;
|
||||
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(
|
||||
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<String, dynamic>? ?? {};
|
||||
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<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 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<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
|
||||
Future<void> restoreEmails(List<model.Email> 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<EmailDetailScreen> {
|
||||
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<EmailDetailScreen> {
|
||||
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) {
|
||||
if (body.headers.isEmpty) {
|
||||
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/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<EmailListScreen> {
|
||||
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<EmailListScreen> {
|
||||
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) {
|
||||
return ListView.builder(
|
||||
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
|
||||
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
|
||||
Future<void> restoreEmails(List<Email> emails) async {}
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ class FakeEmailRepository implements EmailRepository {
|
||||
@override
|
||||
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
|
||||
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),
|
||||
) 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
|
||||
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
||||
(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', () {
|
||||
http.Client mockBodyClient({
|
||||
String text = 'Hello from JMAP',
|
||||
|
||||
@@ -374,6 +374,32 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
returnValue: _i4.Future<bool>.value(false),
|
||||
) 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
|
||||
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
||||
(super.noSuchMethod(
|
||||
|
||||
@@ -209,6 +209,12 @@ class FakeEmailRepository implements EmailRepository {
|
||||
@override
|
||||
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
|
||||
Future<void> restoreEmails(List<Email> emails) async {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user