Fix API mismatches, lint violations, and test failures

- enough_mail: use uidFetchMessage/uidMarkSeen/uidMarkFlagged/uidMove/
  uidMarkDeleted/uidExpunge, remove non-existent isUidSequence param,
  fix SmtpClient construction and use quit() not disconnect()
- Drift: add @DataClassName('MailboxRow') to avoid ugly 'Mailboxe',
  alias core model imports to resolve type name conflicts
- EmailsCompanion.insert: uid/receivedAt are required, not Value<T>
- Lint: remove unrecognised rules (prefer_const_collections,
  avoid_returning_null_for_future), add missing mounted guards after await
- Tests: fix html_utils expectations to match trim() behaviour,
  add explicit Map casts in email_model_test for avoid_dynamic_calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-16 08:21:14 +02:00
co-authored by Claude Sonnet 4.6
parent 03d35387f7
commit 4e03483126
12 changed files with 119 additions and 107 deletions
+21 -6
View File
@@ -24,14 +24,29 @@ android/.gradle/
android/local.properties
android/app/google-services.json
android/key.properties
android/app/src/main/java/io/flutter/plugins/
# iOS / macOS
# iOS
ios/Pods/
macos/Pods/
ios/Flutter/Generated.xcconfig
ios/Flutter/ephemeral/
ios/Flutter/flutter_export_environment.sh
ios/Runner/GeneratedPluginRegistrant.*
*.xcworkspace/
# Linux build output
linux/build/
# macOS
macos/Pods/
macos/Flutter/GeneratedPluginRegistrant.swift
macos/Flutter/ephemeral/
# Stalwart dev server runtime data (created by stalwart-dev/start)
/tmp/stalwart-dev-*/
# Linux generated plugin wiring (recreated by flutter pub get)
linux/flutter/ephemeral/
linux/flutter/generated_plugin_registrant.*
linux/flutter/generated_plugins.cmake
# Flutter generated metadata
.flutter-plugins
.flutter-plugins-dependencies
# direnv cache
.direnv/
+2 -2
View File
@@ -10,6 +10,8 @@ analyzer:
- lib/data/db/database.g.dart
- "**/*.g.dart"
- "**/*.freezed.dart"
# Vendored library — analyze only our own code
- "packages/**"
linter:
rules:
@@ -17,7 +19,6 @@ linter:
- prefer_single_quotes
- prefer_const_constructors
- prefer_const_declarations
- prefer_const_collections
- prefer_final_locals
- prefer_final_in_for_each
- unnecessary_const
@@ -30,7 +31,6 @@ linter:
- avoid_dynamic_calls
- avoid_empty_else
- avoid_print
- avoid_returning_null_for_future
- avoid_returning_null_for_void
- avoid_type_to_string
- cancel_subscriptions
+1
View File
@@ -24,6 +24,7 @@ class Accounts extends Table {
Set<Column> get primaryKey => {id};
}
@DataClassName('MailboxRow')
class Mailboxes extends Table {
TextColumn get id => text()();
TextColumn get accountId =>
+2 -2
View File
@@ -5,7 +5,7 @@ import '../../core/utils/logger.dart';
/// Opens an authenticated IMAP client for [account].
Future<ImapClient> connectImap(Account account, String password) async {
final client = ImapClient(isLogEnabled: false);
final client = ImapClient();
await client.connectToServer(
account.imapHost,
account.imapPort,
@@ -25,7 +25,7 @@ Future<SmtpClient> connectSmtp(Account account, String password) async {
final clientDomain =
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
final client = SmtpClient(clientDomain, isLogEnabled: false);
final client = SmtpClient(clientDomain);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
@@ -1,11 +1,8 @@
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/models/account.dart';
import '../../core/models/account.dart' as model;
import '../../core/repositories/account_repository.dart';
import '../db/database.dart';
import '../db/database.dart' as db show Account;
class AccountRepositoryImpl implements AccountRepository {
AccountRepositoryImpl(this._db);
@@ -14,14 +11,14 @@ class AccountRepositoryImpl implements AccountRepository {
final _storage = const FlutterSecureStorage();
@override
Stream<List<Account>> observeAccounts() {
Stream<List<model.Account>> observeAccounts() {
return _db.select(_db.accounts).watch().map(
(rows) => rows.map(_toModel).toList(),
);
}
@override
Future<Account?> getAccount(String id) async {
Future<model.Account?> getAccount(String id) async {
final row = await (_db.select(_db.accounts)
..where((t) => t.id.equals(id)))
.getSingleOrNull();
@@ -29,7 +26,7 @@ class AccountRepositoryImpl implements AccountRepository {
}
@override
Future<void> addAccount(Account account, String password) async {
Future<void> addAccount(model.Account account, String password) async {
await _db.into(_db.accounts).insertOnConflictUpdate(
AccountsCompanion.insert(
id: account.id,
@@ -55,13 +52,15 @@ class AccountRepositoryImpl implements AccountRepository {
@override
Future<String> getPassword(String accountId) async {
final pw = await _storage.read(key: _passwordKey(accountId));
if (pw == null) throw StateError('No password stored for account $accountId');
if (pw == null) {
throw StateError('No password stored for account $accountId');
}
return pw;
}
String _passwordKey(String accountId) => 'account_password_$accountId';
Account _toModel(db.Account row) => Account(
model.Account _toModel(Account row) => model.Account(
id: row.id,
displayName: row.displayName,
email: row.email,
@@ -3,11 +3,10 @@ import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import '../../core/models/email.dart';
import '../../core/models/email.dart' as model;
import '../../core/repositories/account_repository.dart';
import '../../core/repositories/email_repository.dart';
import '../db/database.dart';
import '../db/database.dart' as db show Email, EmailBody;
import '../imap/imap_client_factory.dart';
class EmailRepositoryImpl implements EmailRepository {
@@ -19,7 +18,10 @@ class EmailRepositoryImpl implements EmailRepository {
// ── Observe ────────────────────────────────────────────────────────────────
@override
Stream<List<Email>> observeEmails(String accountId, String mailboxPath) {
Stream<List<model.Email>> observeEmails(
String accountId,
String mailboxPath,
) {
return (_db.select(_db.emails)
..where(
(t) =>
@@ -32,7 +34,7 @@ class EmailRepositoryImpl implements EmailRepository {
}
@override
Future<Email?> getEmail(String emailId) async {
Future<model.Email?> getEmail(String emailId) async {
final row = await (_db.select(_db.emails)
..where((t) => t.id.equals(emailId)))
.getSingleOrNull();
@@ -42,7 +44,7 @@ class EmailRepositoryImpl implements EmailRepository {
// ── Body (on-demand) ───────────────────────────────────────────────────────
@override
Future<EmailBody> getEmailBody(String emailId) async {
Future<model.EmailBody> getEmailBody(String emailId) async {
final cached = await (_db.select(_db.emailBodies)
..where((t) => t.emailId.equals(emailId)))
.getSingleOrNull();
@@ -56,17 +58,11 @@ class EmailRepositoryImpl implements EmailRepository {
final client = await connectImap(account, password);
try {
await client.selectMailboxByPath(emailRow.mailboxPath);
final fetch = await client.fetchMessage(
imap.MessageSequence.fromId(emailRow.uid, isUid: true),
'(BODY[])',
isUidSequence: true,
);
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY[])');
final msg = fetch.messages.first;
final textBody = msg.decodeTextPlainPart();
final htmlBody = msg.decodeTextHtmlPart();
final contentInfos = msg.findContentInfo(
disposition: imap.ContentDisposition.attachment,
);
final contentInfos = msg.findContentInfo();
final attachmentsJson = jsonEncode(
contentInfos
@@ -88,7 +84,7 @@ class EmailRepositoryImpl implements EmailRepository {
attachmentsJson: Value(attachmentsJson),
),
);
return EmailBody(
return model.EmailBody(
emailId: emailId,
textBody: textBody,
htmlBody: htmlBody,
@@ -117,17 +113,17 @@ class EmailRepositoryImpl implements EmailRepository {
if (envelope == null) continue;
final uid = msg.uid;
if (uid == null) continue;
final emailId = '${accountId}:$uid';
final emailId = '$accountId:$uid';
await _db.into(_db.emails).insertOnConflictUpdate(
EmailsCompanion.insert(
id: emailId,
accountId: accountId,
mailboxPath: mailboxPath,
uid: Value(uid),
uid: uid,
subject: Value(envelope.subject),
sentAt: Value(envelope.date),
receivedAt: Value(envelope.date ?? DateTime.now()),
receivedAt: envelope.date ?? DateTime.now(),
fromJson: Value(_encodeAddresses(envelope.from)),
toJson: Value(_encodeAddresses(envelope.to)),
ccJson: Value(_encodeAddresses(envelope.cc)),
@@ -145,7 +141,11 @@ class EmailRepositoryImpl implements EmailRepository {
// ── Mutations ──────────────────────────────────────────────────────────────
@override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {
Future<void> setFlag(
String emailId, {
bool? seen,
bool? flagged,
}) async {
final row = await (_db.select(_db.emails)
..where((t) => t.id.equals(emailId)))
.getSingle();
@@ -157,13 +157,13 @@ class EmailRepositoryImpl implements EmailRepository {
final seq = imap.MessageSequence.fromId(row.uid, isUid: true);
if (seen != null) {
seen
? await client.markSeen(seq, isUidSequence: true)
: await client.markUnseen(seq, isUidSequence: true);
? await client.uidMarkSeen(seq)
: await client.uidMarkUnseen(seq);
}
if (flagged != null) {
flagged
? await client.markFlagged(seq, isUidSequence: true)
: await client.markUnflagged(seq, isUidSequence: true);
? await client.uidMarkFlagged(seq)
: await client.uidMarkUnflagged(seq);
}
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId)))
.write(
@@ -187,10 +187,9 @@ class EmailRepositoryImpl implements EmailRepository {
final client = await connectImap(account, password);
try {
await client.selectMailboxByPath(row.mailboxPath);
await client.move(
await client.uidMove(
imap.MessageSequence.fromId(row.uid, isUid: true),
targetMailboxPath: destMailboxPath,
isUidSequence: true,
);
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
} finally {
@@ -208,10 +207,9 @@ class EmailRepositoryImpl implements EmailRepository {
final client = await connectImap(account, password);
try {
await client.selectMailboxByPath(row.mailboxPath);
await client.deleteMessages(
imap.MessageSequence.fromId(row.uid, isUid: true),
isUidSequence: true,
);
final seq = imap.MessageSequence.fromId(row.uid, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
} finally {
await client.logout();
@@ -219,7 +217,7 @@ class EmailRepositoryImpl implements EmailRepository {
}
@override
Future<void> sendEmail(String accountId, EmailDraft draft) async {
Future<void> sendEmail(String accountId, model.EmailDraft draft) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
final smtpClient = await connectSmtp(account, password);
@@ -242,27 +240,26 @@ class EmailRepositoryImpl implements EmailRepository {
// ── Helpers ────────────────────────────────────────────────────────────────
String _encodeAddresses(List<imap.MailAddress>? addresses) =>
jsonEncode(
String _encodeAddresses(List<imap.MailAddress>? addresses) => jsonEncode(
(addresses ?? const [])
.map((a) => {'name': a.personalName, 'email': a.email})
.toList(),
);
Email _toModel(db.Email row) {
List<EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List;
model.Email _toModel(Email row) {
List<model.EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => EmailAddress(
name: e['name'] as String?,
(e) => model.EmailAddress(
name: (e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList();
}
return Email(
return model.Email(
id: row.id,
accountId: row.accountId,
mailboxPath: row.mailboxPath,
@@ -280,19 +277,19 @@ class EmailRepositoryImpl implements EmailRepository {
);
}
EmailBody _bodyRowToModel(db.EmailBody row) => EmailBody(
model.EmailBody _bodyRowToModel(EmailBody row) => model.EmailBody(
emailId: row.emailId,
textBody: row.textBody,
htmlBody: row.htmlBody,
attachments: _parseAttachments(row.attachmentsJson),
);
List<EmailAttachment> _parseAttachments(String json) {
final list = jsonDecode(json) as List;
List<model.EmailAttachment> _parseAttachments(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => EmailAttachment(
filename: e['filename'] as String,
(e) => model.EmailAttachment(
filename: (e as Map<String, dynamic>)['filename'] as String,
contentType: e['contentType'] as String,
size: e['size'] as int,
),
@@ -1,12 +1,11 @@
import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import '../../core/models/mailbox.dart';
import '../../core/models/mailbox.dart' as model;
import '../../core/repositories/account_repository.dart';
import '../../core/repositories/mailbox_repository.dart';
import '../db/database.dart';
import '../db/database.dart' as db show Mailbox;
import '../../core/utils/logger.dart';
import '../db/database.dart';
import '../imap/imap_client_factory.dart';
class MailboxRepositoryImpl implements MailboxRepository {
@@ -16,7 +15,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
final AccountRepository _accounts;
@override
Stream<List<Mailbox>> observeMailboxes(String accountId) {
Stream<List<model.Mailbox>> observeMailboxes(String accountId) {
return (_db.select(_db.mailboxes)
..where((t) => t.accountId.equals(accountId))
..orderBy([(t) => OrderingTerm.asc(t.path)]))
@@ -30,16 +29,15 @@ class MailboxRepositoryImpl implements MailboxRepository {
final password = await _accounts.getPassword(accountId);
final client = await connectImap(account, password);
try {
// listMailboxes() returns List<imap.Mailbox>
final mailboxes = await client.listMailboxes(recursive: true);
for (final mb in mailboxes) {
final path = mb.path;
final id = '${accountId}:$path';
final id = '$accountId:$path';
// Fetch STATUS (unread + total counts) for each mailbox.
// Suppress errors — some mailboxes (e.g. \Noselect) can't be selected.
int unread = 0;
int total = 0;
// Fetch STATUS (unread + total counts). Some mailboxes (\Noselect)
// can't be selected — skip counts for those silently.
var unread = 0;
var total = 0;
try {
final status = await client.statusMailbox(
mb,
@@ -48,7 +46,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
unread = status.messagesUnseen;
total = status.messagesExists;
} catch (e) {
// \Noselect mailboxes can't be STATUSed — skip counts silently.
log('STATUS skipped for $path: $e');
}
@@ -68,7 +65,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
}
}
Mailbox _toModel(db.Mailbox row) => Mailbox(
model.Mailbox _toModel(MailboxRow row) => model.Mailbox(
id: row.id,
accountId: row.accountId,
path: row.path,
+1
View File
@@ -61,6 +61,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
.addAccount(account, _password.text);
if (mounted) context.pop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
+1
View File
@@ -83,6 +83,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft);
if (mounted) context.pop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Send failed: $e')));
} finally {
+28 -27
View File
@@ -16,28 +16,26 @@ String _env(String key) {
return v;
}
ImapClient _makeClient() => ImapClient(isLogEnabled: false);
Future<ImapClient> _connect(
String user,
String pass, {
String host = '127.0.0.1',
int? port,
required String host,
required int port,
}) async {
final p = port ?? int.parse(_env('STALWART_IMAP_PORT'));
final client = _makeClient();
await client.connectToServer(host, p, isSecure: false);
final client = ImapClient();
await client.connectToServer(host, port, isSecure: false);
await client.login(user, pass);
return client;
}
void main() {
final imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
late String imapHost;
late int imapPort;
late int smtpPort;
late String userA, passA, userB, passB;
setUpAll(() {
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
imapPort = int.parse(_env('STALWART_IMAP_PORT'));
smtpPort = int.parse(_env('STALWART_SMTP_PORT'));
userA = _env('STALWART_USER_B'); // alice
@@ -47,26 +45,25 @@ void main() {
});
test('login and list mailboxes', () async {
final client = await _connect(userA, passA, host: imapHost, port: imapPort);
final client = await _connect(
userA,
passA,
host: imapHost,
port: imapPort,
);
addTearDown(() => client.logout().ignore());
final response = await client.listMailboxes();
expect(response.mailboxes, isNotEmpty);
expect(
response.mailboxes!.map((m) => m.name),
contains('INBOX'),
);
// listMailboxes() returns List<Mailbox> directly
final mailboxes = await client.listMailboxes();
expect(mailboxes, isNotEmpty);
expect(mailboxes.map((m) => m.name), contains('INBOX'));
});
test('send via SMTP and receive via IMAP', () async {
final smtpClient = SmtpClient('test', isLogEnabled: false);
final smtpClient = SmtpClient('test');
await smtpClient.connectToServer(imapHost, smtpPort, isSecure: false);
await smtpClient.ehlo();
await smtpClient.authenticate(
'$userA@localhost',
passA,
AuthMechanism.plain,
);
await smtpClient.authenticate('$userA@localhost', passA);
final builder = MessageBuilder()
..from = [MailAddress('Alice', '$userA@localhost')]
@@ -74,16 +71,20 @@ void main() {
..subject = 'Integration test ${DateTime.now().millisecondsSinceEpoch}'
..text = 'Hello from SharedInbox integration test.';
await smtpClient.sendMessage(builder.buildMimeMessage());
smtpClient.disconnect();
await smtpClient.quit();
// Give Stalwart a moment to deliver the message.
await Future.delayed(const Duration(milliseconds: 500));
await Future<void>.delayed(const Duration(milliseconds: 500));
final imapClient =
await _connect(userB, passB, host: imapHost, port: imapPort);
final imapClient = await _connect(
userB,
passB,
host: imapHost,
port: imapPort,
);
addTearDown(() => imapClient.logout().ignore());
final mailbox = await imapClient.selectMailboxByPath('INBOX');
expect(mailbox.messagesExists, greaterThan(0));
final inbox = await imapClient.selectMailboxByPath('INBOX');
expect(inbox.messagesExists, greaterThan(0));
});
}
+5 -5
View File
@@ -13,11 +13,11 @@ String encodeAddresses(List<EmailAddress> addresses) => jsonEncode(
);
List<EmailAddress> decodeAddresses(String json) {
final list = jsonDecode(json) as List;
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => EmailAddress(
name: e['name'] as String?,
name: (e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
@@ -78,9 +78,9 @@ void main() {
group('EmailDraft', () {
test('constructs with required fields', () {
final draft = EmailDraft(
from: const EmailAddress(name: 'Me', email: 'me@example.com'),
to: [const EmailAddress(email: 'you@example.com')],
const draft = EmailDraft(
from: EmailAddress(name: 'Me', email: 'me@example.com'),
to: [EmailAddress(email: 'you@example.com')],
cc: [],
subject: 'Hello',
body: 'World',
+2 -2
View File
@@ -21,7 +21,7 @@ void main() {
});
test('converts <p> to newline', () {
expect(htmlToPlain('<p>paragraph</p>'), '\nparagraph');
expect(htmlToPlain('<p>paragraph</p>'), 'paragraph');
});
test('decodes &amp;', () {
@@ -53,7 +53,7 @@ void main() {
});
test('handles nested tags', () {
expect(htmlToPlain('<div><p>text</p></div>'), '\ntext');
expect(htmlToPlain('<div><p>text</p></div>'), 'text');
});
test('real-world HTML email snippet', () {