- Add `format` task (fvm dart format .) and pre-commit dart-format hook - Fix pre-commit task-check hook to use nix develop --command task - Add CI format-check step (dart format --set-exit-if-changed .) - Enable directives_ordering, curly_braces_in_flow_control_structures, discarded_futures, unnecessary_await_in_return, require_trailing_commas - Apply 330 trailing-comma fixes (dart fix --apply) across all files - Wrap intentional fire-and-forget futures with unawaited() to satisfy discarded_futures lint in account_sync_manager, email_repository_impl, and UI screens - Add test/integration/email_repository_imap_test.dart: 8 tests against real Stalwart (sync, body fetch+cache, send, search, flag/move/delete) - Remove 14 fake-IMAP unit tests migrated to Stalwart integration tests - Fix flushPendingChanges move test: create Trash folder before IMAP MOVE - Lower coverage gate 85%→80%: IMAP paths now tested by Stalwart (real), not counted in unit-test lcov - Delete LINTING.md (plan fully executed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
209 lines
7.6 KiB
Dart
209 lines
7.6 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
part 'database.g.dart';
|
|
|
|
// ── Tables ────────────────────────────────────────────────────────────────────
|
|
|
|
class Accounts extends Table {
|
|
TextColumn get id => text()();
|
|
TextColumn get displayName => text()();
|
|
TextColumn get email => text()();
|
|
TextColumn get imapHost => text()();
|
|
IntColumn get imapPort => integer()();
|
|
BoolColumn get imapSsl => boolean()();
|
|
TextColumn get smtpHost => text()();
|
|
IntColumn get smtpPort => integer()();
|
|
BoolColumn get smtpSsl => boolean()();
|
|
// Added in schema v2:
|
|
TextColumn get accountType => text().withDefault(const Constant('imap'))();
|
|
TextColumn get jmapUrl => text().nullable()();
|
|
// Added in schema v3:
|
|
TextColumn get username => text().withDefault(const Constant(''))();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
@DataClassName('MailboxRow')
|
|
class Mailboxes extends Table {
|
|
TextColumn get id => text()();
|
|
TextColumn get accountId =>
|
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
|
TextColumn get path => text()();
|
|
TextColumn get name => text()();
|
|
IntColumn get unreadCount => integer().withDefault(const Constant(0))();
|
|
IntColumn get totalCount => integer().withDefault(const Constant(0))();
|
|
// Added in schema v8: JMAP role (e.g. "inbox", "sent", "trash").
|
|
TextColumn get role => text().nullable()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
class Emails extends Table {
|
|
TextColumn get id => text()();
|
|
TextColumn get accountId =>
|
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
|
TextColumn get mailboxPath => text()();
|
|
IntColumn get uid => integer()();
|
|
TextColumn get subject => text().nullable()();
|
|
DateTimeColumn get sentAt => dateTime().nullable()();
|
|
DateTimeColumn get receivedAt => dateTime()();
|
|
// JSON-encoded List<{name,email}>
|
|
TextColumn get fromJson => text().withDefault(const Constant('[]'))();
|
|
TextColumn get toAddresses => text().withDefault(const Constant('[]'))();
|
|
TextColumn get ccJson => text().withDefault(const Constant('[]'))();
|
|
TextColumn get preview => text().nullable()();
|
|
BoolColumn get isSeen => boolean().withDefault(const Constant(false))();
|
|
BoolColumn get isFlagged => boolean().withDefault(const Constant(false))();
|
|
BoolColumn get hasAttachment =>
|
|
boolean().withDefault(const Constant(false))();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
class EmailBodies extends Table {
|
|
TextColumn get emailId =>
|
|
text().references(Emails, #id, onDelete: KeyAction.cascade)();
|
|
TextColumn get textBody => text().nullable()();
|
|
TextColumn get htmlBody => text().nullable()();
|
|
// JSON-encoded List<{filename,contentType,size}>
|
|
TextColumn get attachmentsJson => text().withDefault(const Constant('[]'))();
|
|
// Added in schema v9: when the body was last fetched from the server.
|
|
// Null for rows cached before this column was added (treated as expired).
|
|
DateTimeColumn get cachedAt => dateTime().nullable()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {emailId};
|
|
}
|
|
|
|
/// Protocol-agnostic outbound change queue.
|
|
/// Local mutations are written here before being sent to the server,
|
|
/// enabling offline-first behaviour and durable retries.
|
|
@DataClassName('PendingChangeRow')
|
|
class PendingChanges extends Table {
|
|
IntColumn get id => integer().autoIncrement()();
|
|
TextColumn get accountId =>
|
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
|
TextColumn get resourceType => text()();
|
|
TextColumn get resourceId => text()();
|
|
// "flag_seen" | "flag_flagged" | "move" | "delete"
|
|
TextColumn get changeType => text()();
|
|
// JSON payload, e.g. {"seen": true} or {"dest": "Archive"}
|
|
TextColumn get payload => text()();
|
|
DateTimeColumn get createdAt => dateTime()();
|
|
IntColumn get attempts => integer().withDefault(const Constant(0))();
|
|
TextColumn get lastError => text().nullable()();
|
|
}
|
|
|
|
/// Sync checkpoint per (account, resource type).
|
|
/// Stores the server-side state token used for incremental sync.
|
|
/// For JMAP: the opaque `state` string from Mailbox/get or Email/get.
|
|
/// For IMAP: a JSON object with last-synced UID / MODSEQ per mailbox.
|
|
@DataClassName('SyncStateRow')
|
|
class SyncStates extends Table {
|
|
TextColumn get accountId =>
|
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
|
TextColumn get resourceType => text()();
|
|
TextColumn get state => text()();
|
|
DateTimeColumn get syncedAt => dateTime()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {accountId, resourceType};
|
|
}
|
|
|
|
/// Lightweight audit trail for each sync cycle.
|
|
/// Useful for debugging and surfacing "last synced" timestamps in the UI.
|
|
@DataClassName('SyncLogRow')
|
|
class SyncLogs extends Table {
|
|
IntColumn get id => integer().autoIncrement()();
|
|
TextColumn get accountId =>
|
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
|
// "ok" | "error"
|
|
TextColumn get result => text()();
|
|
TextColumn get errorMessage => text().nullable()();
|
|
IntColumn get itemsSynced => integer().withDefault(const Constant(0))();
|
|
DateTimeColumn get startedAt => dateTime()();
|
|
DateTimeColumn get finishedAt => dateTime()();
|
|
}
|
|
|
|
/// Auto-saved compose drafts — persisted across app restarts.
|
|
class Drafts extends Table {
|
|
IntColumn get id => integer().autoIncrement()();
|
|
TextColumn get accountId => text().nullable()();
|
|
|
|
/// Set for replies/reply-alls; null for new messages.
|
|
TextColumn get replyToEmailId => text().nullable()();
|
|
TextColumn get toText => text().withDefault(const Constant(''))();
|
|
TextColumn get ccText => text().withDefault(const Constant(''))();
|
|
TextColumn get subjectText => text().withDefault(const Constant(''))();
|
|
TextColumn get bodyText => text().withDefault(const Constant(''))();
|
|
DateTimeColumn get updatedAt => dateTime()();
|
|
}
|
|
|
|
// ── Database ──────────────────────────────────────────────────────────────────
|
|
|
|
@DriftDatabase(
|
|
tables: [
|
|
Accounts,
|
|
Mailboxes,
|
|
Emails,
|
|
EmailBodies,
|
|
Drafts,
|
|
SyncStates,
|
|
PendingChanges,
|
|
SyncLogs,
|
|
],
|
|
)
|
|
class AppDatabase extends _$AppDatabase {
|
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
|
|
|
@override
|
|
int get schemaVersion => 9;
|
|
|
|
@override
|
|
MigrationStrategy get migration => MigrationStrategy(
|
|
onUpgrade: (m, from, to) async {
|
|
if (from < 2) {
|
|
await m.addColumn(accounts, accounts.accountType);
|
|
await m.addColumn(accounts, accounts.jmapUrl);
|
|
}
|
|
if (from < 3) {
|
|
await m.addColumn(accounts, accounts.username);
|
|
}
|
|
if (from < 4) {
|
|
await m.createTable(drafts);
|
|
}
|
|
if (from < 5) {
|
|
await m.createTable(syncStates);
|
|
}
|
|
if (from < 6) {
|
|
await m.createTable(pendingChanges);
|
|
}
|
|
if (from < 7) {
|
|
await m.createTable(syncLogs);
|
|
}
|
|
if (from < 8) {
|
|
await m.addColumn(mailboxes, mailboxes.role);
|
|
}
|
|
if (from < 9) {
|
|
await m.addColumn(emailBodies, emailBodies.cachedAt);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
LazyDatabase _openConnection() {
|
|
return LazyDatabase(() async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final file = File(p.join(dir.path, 'sharedinbox.db'));
|
|
return NativeDatabase.createInBackground(file);
|
|
});
|
|
}
|