Files
sharedinbox/lib/data/db/database.dart
T
Thomas GüttlerandClaude Sonnet 4.6 be56232f00 feat: linting + format automation + IMAP integration tests against Stalwart
- 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>
2026-04-20 18:08:09 +02:00

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);
});
}