Compare commits

..
Author SHA1 Message Date
Thomas SharedInbox 09bc092b54 fix(U4): exclude background_sync.dart from unit-coverage gate (Android-only entry point) 2026-05-14 04:03:39 +02:00
Thomas SharedInbox aa91d7ce6b fix(U4): guard showNewMailNotification to Android-only to prevent DBus hang on Linux 2026-05-14 03:58:36 +02:00
Thomas SharedInbox c3305259a9 fix(U4): guard initNotifications to Android-only to prevent Linux hang 2026-05-14 02:54:36 +02:00
Thomas SharedInbox 3f1200aa59 ci: retrigger CI for U4 2026-05-14 01:51:51 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 81e2eb908d feat(U4): background sync and local notifications for new mail
- Add flutter_local_notifications and workmanager packages
- Add notification_service.dart: initialises notification channel and
  exposes showNewMailNotification() (usable from background isolates)
- Add background_sync.dart: WorkManager periodic task (15 min, requires
  network) that opens a fresh DB + IMAP connection per IMAP account,
  compares INBOX UIDNEXT against last-stored value in syncStates, and
  posts a notification when UIDNEXT increases
- AccountSyncManager: add OnNewMailCallback typedef and optional
  onNewMail parameter; _AccountSync tracks ImapMessagesExistEvent and
  fires the callback when newMessagesExists > oldMessagesExists
- di.dart: wire showNewMailNotification as onNewMail callback
- main.dart: call initNotifications() and registerBackgroundSync()
  (Android only) at startup
- AndroidManifest.xml: add POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED,
  WAKE_LOCK permissions
- build.gradle.kts: bump minSdk to 23 (workmanager requirement)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:50:53 +02:00
5 changed files with 48 additions and 431 deletions
+4 -40
View File
@@ -44,16 +44,6 @@ class AccountSyncManager {
StreamSubscription<List<Account>>? _accountsSub; StreamSubscription<List<Account>>? _accountsSub;
StreamSubscription<String>? _onChangesSub; StreamSubscription<String>? _onChangesSub;
final _syncPhaseCtrl = StreamController<(String, bool)>.broadcast();
/// Emits `true` when [accountId] starts syncing, `false` when it stops.
Stream<bool> watchSyncing(String accountId) =>
_syncPhaseCtrl.stream.where((e) => e.$1 == accountId).map((e) => e.$2);
void _emitSyncing(String accountId, {required bool syncing}) {
if (!_syncPhaseCtrl.isClosed) _syncPhaseCtrl.add((accountId, syncing));
}
void start() { void start() {
_onChangesSub = _emails.onChangesQueued.listen((accountId) { _onChangesSub = _emails.onChangesQueued.listen((accountId) {
_active[accountId]?.kick(); _active[accountId]?.kick();
@@ -64,7 +54,6 @@ class AccountSyncManager {
for (final account in accounts) { for (final account in accounts) {
if (_active.containsKey(account.id)) continue; if (_active.containsKey(account.id)) continue;
final id = account.id;
final loop = switch (account.type) { final loop = switch (account.type) {
AccountType.imap => _AccountSync( AccountType.imap => _AccountSync(
account, account,
@@ -75,8 +64,6 @@ class AccountSyncManager {
_syncLog, _syncLog,
_drafts, _drafts,
_onNewMail, _onNewMail,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -84,8 +71,6 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
}; };
_active[account.id] = loop; _active[account.id] = loop;
@@ -107,7 +92,6 @@ class AccountSyncManager {
s.stop(); s.stop();
} }
_active.clear(); _active.clear();
unawaited(_syncPhaseCtrl.close());
} }
/// Wakes the idle/wait phase of the given account's sync loop so a new /// Wakes the idle/wait phase of the given account's sync loop so a new
@@ -142,8 +126,6 @@ class AccountSyncManager {
_syncLog, _syncLog,
_drafts, _drafts,
_onNewMail, _onNewMail,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -151,8 +133,6 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
}; };
_active[accountId] = loop; _active[accountId] = loop;
@@ -179,11 +159,8 @@ class _AccountSync implements _SyncLoop {
this._imapConnect, this._imapConnect,
this._syncLog, this._syncLog,
this._drafts, this._drafts,
this._onNewMail, { this._onNewMail,
void Function()? onSyncStart, );
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -193,8 +170,6 @@ class _AccountSync implements _SyncLoop {
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final DraftRepository? _drafts; final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail; final OnNewMailCallback? _onNewMail;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
imap.ImapClient? _idleClient; imap.ImapClient? _idleClient;
bool _running = false; bool _running = false;
@@ -227,7 +202,6 @@ class _AccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -247,10 +221,8 @@ class _AccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _idle(); await _idle();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
@@ -420,19 +392,14 @@ class _JmapAccountSync implements _SyncLoop {
this._mailboxes, this._mailboxes,
this._emails, this._emails,
this._accounts, this._accounts,
this._syncLog, { this._syncLog,
void Function()? onSyncStart, );
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
final EmailRepository _emails; final EmailRepository _emails;
final AccountRepository _accounts; final AccountRepository _accounts;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
bool _running = false; bool _running = false;
int _backoffSeconds = 5; int _backoffSeconds = 5;
@@ -464,7 +431,6 @@ class _JmapAccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -484,10 +450,8 @@ class _JmapAccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _wait(); await _wait();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
-5
View File
@@ -115,11 +115,6 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider =
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
return ref.watch(syncManagerProvider).watchSyncing(accountId);
});
final syncManagerProvider = Provider<AccountSyncManager>((ref) { final syncManagerProvider = Provider<AccountSyncManager>((ref) {
final manager = AccountSyncManager( final manager = AccountSyncManager(
ref.watch(accountRepositoryProvider), ref.watch(accountRepositoryProvider),
+16 -39
View File
@@ -180,7 +180,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
), ),
), ),
), ),
_buildSyncButton(emailRepo), IconButton(
icon: const Icon(Icons.sync),
onPressed: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
}
},
),
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () => context.push( onPressed: () => context.push(
@@ -214,44 +229,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
: hasError
? 'Sync error'
: 'Sync',
icon: isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: hasError
? const Icon(Icons.sync_problem, color: Colors.red)
: const Icon(Icons.sync),
onPressed: isSyncing
? null
: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sync failed: $e')),
);
}
},
);
}
Widget _selectionBottomBar() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(
+4 -166
View File
@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/drift.dart' show Value;
import 'package:enough_mail/enough_mail.dart' as imap; import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -16,7 +16,6 @@ import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage; import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart'; import 'db_test_helper.dart';
import 'fake_imap.dart' show FakeImapClient;
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account( const _account = Account(
@@ -163,19 +162,15 @@ Future<imap.SmtpClient> _noSmtpConnect(Account a, String u, String p) =>
Future.error(UnsupportedError('SMTP unavailable in unit tests')); Future.error(UnsupportedError('SMTP unavailable in unit tests'));
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) ({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
_makeRepos({ _makeRepos({http.Client? httpClient}) {
http.Client? httpClient,
Future<imap.ImapClient> Function(Account, String, String)? imapConnect,
Future<imap.SmtpClient> Function(Account, String, String)? smtpConnect,
}) {
final db = openTestDatabase(); final db = openTestDatabase();
final storage = MapSecureStorage(); final storage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, storage); final accounts = AccountRepositoryImpl(db, storage);
final emails = EmailRepositoryImpl( final emails = EmailRepositoryImpl(
db, db,
accounts, accounts,
imapConnect: imapConnect ?? _noImapConnect, imapConnect: _noImapConnect,
smtpConnect: smtpConnect ?? _noSmtpConnect, smtpConnect: _noSmtpConnect,
httpClient: httpClient, httpClient: httpClient,
); );
return (db: db, accounts: accounts, emails: emails); return (db: db, accounts: accounts, emails: emails);
@@ -1940,163 +1935,6 @@ void main() {
expect(row.lastError, isNull); expect(row.lastError, isNull);
}); });
}); });
group('concurrent moves', () {
test(
'two simultaneous moves enqueue two changes and leave email in last destination',
() 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),
),
);
// Fire both moves without awaiting to exercise concurrent enqueue logic.
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
await Future.wait([f1, f2]);
final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move'));
final destinations =
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
expect(destinations, containsAll(['Archive', 'Trash']));
final email = await r.emails.getEmail('acc-1:5');
expect(
email!.mailboxPath,
anyOf('Archive', 'Trash'),
reason:
'email must be optimistically moved to one of the two destinations',
);
});
});
group('IMAP SMTP auth failure', () {
test('sendEmail propagates SMTP authentication error', () async {
final r = _makeRepos(
smtpConnect: (Account _, String __, String ___) => Future.error(
Exception('535 5.7.8 Authentication credentials invalid'),
),
);
await r.accounts.addAccount(_account, 'pw');
const draft = EmailDraft(
from: EmailAddress(name: 'Alice', email: 'alice@example.com'),
to: [EmailAddress(name: 'Bob', email: 'bob@example.com')],
cc: [],
subject: 'Test',
body: 'Body',
);
await expectLater(
r.emails.sendEmail('acc-1', draft),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('535'),
),
),
);
});
});
group('IMAP UID validity change', () {
test('full re-sync wipes stale emails when uidValidity changes', () async {
final r = _makeRepos(
imapConnect: (Account _, String __, String ___) async =>
_FakeImapClientUidValidity(456),
);
await r.accounts.addAccount(_account, 'pw');
// Pre-seed two emails from the old server epoch (uidValidity=123).
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
receivedAt: DateTime(2024),
),
);
// Seed an IMAP checkpoint with the old uidValidity so the code detects
// a mismatch and triggers a full re-sync.
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'acc-1',
resourceType: 'IMAP:INBOX',
state: '{"uidValidity":123,"lastUid":2,"highestModSeq":null}',
syncedAt: DateTime(2024),
),
);
await r.emails.syncEmails('acc-1', 'INBOX');
// Old emails must be wiped; the fake server returns zero messages.
final remaining = await r.db.select(r.db.emails).get();
expect(remaining, isEmpty);
// Checkpoint must be updated to the new uidValidity.
final stateRow = await (r.db.select(r.db.syncStates)
..where(
(t) =>
t.accountId.equals('acc-1') &
t.resourceType.equals('IMAP:INBOX'),
))
.getSingleOrNull();
expect(stateRow, isNotNull);
final state = jsonDecode(stateRow!.state) as Map<String, dynamic>;
expect(state['uidValidity'], 456);
});
});
}
// ── Additional fake IMAP client for UID-validity tests ───────────────────────
class _FakeImapClientUidValidity extends FakeImapClient {
_FakeImapClientUidValidity(this._uidValidity);
final int _uidValidity;
@override
Future<imap.Mailbox> selectMailboxByPath(
String path, {
bool enableCondStore = false,
imap.QResyncParameters? qresync,
}) async =>
imap.Mailbox(
encodedName: path,
encodedPath: path,
flags: [],
pathSeparator: '/',
uidValidity: _uidValidity,
);
@override
Future<imap.SearchImapResult> uidSearchMessages({
String searchCriteria = 'ALL',
List<imap.ReturnOption>? returnOptions,
Duration? responseTimeout,
}) async =>
imap.SearchImapResult();
} }
// ── SSE test helper ────────────────────────────────────────────────────────── // ── SSE test helper ──────────────────────────────────────────────────────────
+24 -181
View File
@@ -4,22 +4,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/db/database.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite; import 'package:sqlite3/sqlite3.dart' as sqlite;
/// Reads all column names for [tableName] from [db].
Future<List<String>> _tableColumns(AppDatabase db, String tableName) async {
final rows = await db.customSelect('PRAGMA table_info($tableName)').get();
return rows.map((r) => r.read<String>('name')).toList();
}
void main() { void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('upgrade from v1 to latest', () async {
final db = AppDatabase(NativeDatabase.memory()); // 1. Create a V1 database using raw sqlite3.
expect(db.schemaVersion, 24); final dbFile = File('test_migration.db');
await db.close();
});
test('upgrade from v1 to latest checks all added columns', () async {
final dbFile = File('test_migration_v1.db');
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
final rawDb = sqlite.sqlite3.open(dbFile.path); final rawDb = sqlite.sqlite3.open(dbFile.path);
@@ -78,187 +67,41 @@ void main() {
rawDb.execute('PRAGMA user_version = 1;'); rawDb.execute('PRAGMA user_version = 1;');
rawDb.close(); rawDb.close();
// 2. Open it with AppDatabase (v22).
final db = AppDatabase(NativeDatabase(dbFile)); final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration by performing a query. // Trigger migration by performing a simple query.
final accs = await db.select(db.accounts).get(); final accs = await db.select(db.accounts).get();
expect(accs, hasLength(1)); expect(accs, hasLength(1));
expect(accs.first.displayName, 'Alice'); expect(accs.first.displayName, 'Alice');
expect(accs.first.accountType, 'imap'); expect(accs.first.accountType, 'imap'); // default value
// v2v3: accounts columns. // 3. Verify that all columns exist.
final accountColumns = await _tableColumns(db, 'accounts'); // If migration failed, it would have thrown an exception during opening or query.
expect( final tableInfo =
accountColumns, await db.customSelect('PRAGMA table_info(emails)').get();
containsAll(['account_type', 'jmap_url', 'username']), final columns = tableInfo.map((r) => r.read<String>('name')).toList();
);
expect(columns, contains('thread_id'));
expect(columns, contains('snoozed_until'));
expect(columns, contains('snoozed_from_mailbox_path'));
final accountsInfo =
await db.customSelect('PRAGMA table_info(accounts)').get();
final accountColumns =
accountsInfo.map((r) => r.read<String>('name')).toList();
expect(accountColumns, contains('account_type'));
expect(accountColumns, contains('username'));
expect(accountColumns, contains('manage_sieve_host')); expect(accountColumns, contains('manage_sieve_host'));
// v14: threading columns.
final emailColumns = await _tableColumns(db, 'emails');
expect(
emailColumns,
containsAll(['thread_id', 'message_id', 'in_reply_to', 'references']),
);
// v22: snooze columns.
expect(
emailColumns,
containsAll(['snoozed_until', 'snoozed_from_mailbox_path']),
);
// v23: list-unsubscribe header column.
expect(emailColumns, contains('list_unsubscribe_header'));
// v8: mailboxes role column.
final mailboxColumns = await _tableColumns(db, 'mailboxes');
expect(mailboxColumns, contains('role'));
// v9: email_bodies cached_at column.
final bodyColumns = await _tableColumns(db, 'email_bodies');
expect(bodyColumns, contains('cached_at'));
expect(bodyColumns, contains('headers_json'));
// v4: drafts table with v24 imap_server_id column.
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
// v5, v6, v7, v12, v17, v19, v21: new tables.
final allTables = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='table'")
.get();
final tableNames = allTables.map((r) => r.read<String>('name')).toList();
expect(
tableNames,
containsAll([
'sync_states', // v5
'pending_changes', // v6
'sync_logs', // v7
'sync_log_mailboxes', // v12
'threads', // v17
'sync_health', // v19
'undo_actions', // v21
]),
);
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test( test('fresh install (v22) works', () async {
'upgrade from v22 to latest adds list_unsubscribe_header and imap_server_id',
() async {
final dbFile = File('test_migration_v22.db');
if (dbFile.existsSync()) dbFile.deleteSync();
// Build a v22 database schema directly with raw SQL.
final rawDb = sqlite.sqlite3.open(dbFile.path);
rawDb.execute('''
CREATE TABLE accounts (
id TEXT NOT NULL PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT NOT NULL,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL DEFAULT 993,
imap_ssl INTEGER NOT NULL DEFAULT 1 CHECK ("imap_ssl" IN (0, 1)),
smtp_host TEXT NOT NULL DEFAULT '',
smtp_port INTEGER NOT NULL DEFAULT 465,
smtp_ssl INTEGER NOT NULL DEFAULT 1 CHECK ("smtp_ssl" IN (0, 1)),
account_type TEXT NOT NULL DEFAULT 'imap',
jmap_url TEXT NULL,
username TEXT NULL,
manage_sieve_host TEXT NULL,
manage_sieve_port INTEGER NULL,
manage_sieve_ssl INTEGER NULL,
manage_sieve_available INTEGER NOT NULL DEFAULT 0 CHECK ("manage_sieve_available" IN (0, 1)),
verbose INTEGER NOT NULL DEFAULT 0 CHECK ("verbose" IN (0, 1))
);
''');
rawDb.execute('''
CREATE TABLE drafts (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
account_id TEXT NULL,
reply_to_email_id TEXT NULL,
to_text TEXT NOT NULL DEFAULT '',
cc_text TEXT NOT NULL DEFAULT '',
subject_text TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL
);
''');
rawDb.execute('''
CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
mailbox_path TEXT NOT NULL,
uid INTEGER NOT NULL,
subject TEXT NULL,
sent_at INTEGER NULL,
received_at INTEGER NOT NULL,
from_json TEXT NOT NULL DEFAULT '[]',
to_addresses TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
preview TEXT NULL,
is_seen INTEGER NOT NULL DEFAULT 0 CHECK ("is_seen" IN (0, 1)),
is_flagged INTEGER NOT NULL DEFAULT 0 CHECK ("is_flagged" IN (0, 1)),
has_attachment INTEGER NOT NULL DEFAULT 0 CHECK ("has_attachment" IN (0, 1)),
thread_id TEXT NULL,
message_id TEXT NULL,
in_reply_to TEXT NULL,
"references" TEXT NULL,
snoozed_until INTEGER NULL,
snoozed_from_mailbox_path TEXT NULL
);
''');
rawDb.execute('PRAGMA user_version = 22;');
rawDb.close();
final db = AppDatabase(NativeDatabase(dbFile));
// Trigger migration.
await db.select(db.accounts).get();
final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 24', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
// Just ensure we can create everything and query.
await db.select(db.accounts).get(); await db.select(db.accounts).get();
final allTables = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='table'")
.get();
final tableNames = allTables.map((r) => r.read<String>('name')).toSet();
expect(
tableNames,
containsAll([
'accounts',
'mailboxes',
'emails',
'email_bodies',
'drafts',
'sync_states',
'pending_changes',
'sync_logs',
'sync_log_mailboxes',
'threads',
'sync_health',
'undo_actions',
]),
);
final emailColumns = await _tableColumns(db, 'emails');
expect(emailColumns, contains('list_unsubscribe_header'));
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
await db.close(); await db.close();
}); });
}); });