Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf59cf4621 | ||
|
|
44e387bfb3 | ||
|
|
546b06ba5a | ||
|
|
5ba24a66e0 | ||
|
|
4f16587564 |
@@ -27,6 +27,7 @@ abstract class EmailRepository {
|
||||
Future<EmailBody> getEmailBody(String emailId);
|
||||
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
|
||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath);
|
||||
Future<void> moveEmail(String emailId, String destMailboxPath);
|
||||
|
||||
/// Deletes the email. Returns the path of the mailbox it was moved to
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
|
||||
show ImapConnectFn, connectImap, verboseLogKey;
|
||||
import 'package:sharedinbox/data/imap/tls_error.dart' show isTlsConfigError;
|
||||
|
||||
typedef OnNewMailCallback = Future<void> Function(String accountEmail);
|
||||
|
||||
@@ -291,6 +292,7 @@ class _AccountSync implements _SyncLoop {
|
||||
}
|
||||
|
||||
bool _isPermanentError(Object e) {
|
||||
if (isTlsConfigError(e)) return true;
|
||||
final s = e.toString().toLowerCase();
|
||||
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
|
||||
return s.contains('invalid credentials') ||
|
||||
@@ -528,6 +530,7 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
}
|
||||
|
||||
bool _isPermanentError(Object e) {
|
||||
if (isTlsConfigError(e)) return true;
|
||||
final s = e.toString().toLowerCase();
|
||||
return s.contains('invalid credentials') ||
|
||||
s.contains('authentication failed') ||
|
||||
|
||||
@@ -21,15 +21,52 @@ class TlsModeMismatchException implements Exception {
|
||||
'STARTTLS). Original error: $original';
|
||||
}
|
||||
|
||||
/// If [error] is a TLS handshake failure caused by a wrong-version-number
|
||||
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
|
||||
/// with [host]/[port] context. Otherwise rethrow [error] unchanged.
|
||||
/// Wraps a TLS certificate verification failure into a user-actionable message.
|
||||
///
|
||||
/// Thrown when the server's certificate cannot be verified — either because it
|
||||
/// is self-signed, expired, or the CA chain has changed since the account was
|
||||
/// set up.
|
||||
class TlsCertificateException implements Exception {
|
||||
TlsCertificateException(this.host, this.port, this.original);
|
||||
final String host;
|
||||
final int port;
|
||||
final Object original;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TLS certificate error on $host:$port — the server certificate could '
|
||||
'not be verified. The certificate may have changed or expired. '
|
||||
'Please re-check your account settings or contact your mail provider. '
|
||||
'Original error: $original';
|
||||
}
|
||||
|
||||
/// Returns true if [error] is a permanent TLS configuration error that will
|
||||
/// not resolve on its own and requires user action.
|
||||
bool isTlsConfigError(Object error) =>
|
||||
error is TlsModeMismatchException || error is TlsCertificateException;
|
||||
|
||||
/// If [error] is a recognisable TLS handshake failure, wraps it in a typed
|
||||
/// exception and throws it. Otherwise rethrows [error] unchanged.
|
||||
///
|
||||
/// Recognised patterns:
|
||||
/// - `WRONG_VERSION_NUMBER` → [TlsModeMismatchException] (port/mode mismatch)
|
||||
/// - `CERTIFICATE_VERIFY_FAILED` / `HandshakeException` → [TlsCertificateException]
|
||||
Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
|
||||
if (error.toString().contains('WRONG_VERSION_NUMBER')) {
|
||||
final s = error.toString();
|
||||
if (s.contains('WRONG_VERSION_NUMBER')) {
|
||||
Error.throwWithStackTrace(
|
||||
TlsModeMismatchException(host, port, error),
|
||||
stack,
|
||||
);
|
||||
}
|
||||
if (s.contains('CERTIFICATE_VERIFY_FAILED') ||
|
||||
s.contains('HandshakeException') ||
|
||||
s.contains('CERTIFICATE_EXPIRED') ||
|
||||
s.contains('CERTIFICATE_UNKNOWN')) {
|
||||
Error.throwWithStackTrace(
|
||||
TlsCertificateException(host, port, error),
|
||||
stack,
|
||||
);
|
||||
}
|
||||
Error.throwWithStackTrace(error, stack);
|
||||
}
|
||||
|
||||
@@ -1520,6 +1520,63 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final unread = await (_db.select(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath) &
|
||||
t.isSeen.equals(false),
|
||||
))
|
||||
.get();
|
||||
if (unread.isEmpty) return;
|
||||
|
||||
await _db.transaction(() async {
|
||||
for (final row in unread) {
|
||||
if (account.type == account_model.AccountType.jmap) {
|
||||
await _enqueueChange(
|
||||
accountId,
|
||||
row.id,
|
||||
'flag_seen',
|
||||
jsonEncode({'seen': true}),
|
||||
);
|
||||
} else {
|
||||
await _enqueueChange(
|
||||
accountId,
|
||||
row.id,
|
||||
'flag_seen',
|
||||
jsonEncode({
|
||||
'uid': row.uid,
|
||||
'mailboxPath': row.mailboxPath,
|
||||
'seen': true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk mark all unread emails in this mailbox as seen.
|
||||
await (_db.update(_db.emails)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath) &
|
||||
t.isSeen.equals(false),
|
||||
))
|
||||
.write(const EmailsCompanion(isSeen: Value(true)));
|
||||
|
||||
// Update all threads in this mailbox to reflect no unread.
|
||||
await (_db.update(_db.threads)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals(accountId) &
|
||||
t.mailboxPath.equals(mailboxPath),
|
||||
))
|
||||
.write(const ThreadsCompanion(hasUnread: Value(false)));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
||||
final row = await (_db.select(
|
||||
|
||||
@@ -193,6 +193,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
extra: {'accountId': widget.accountId},
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
if (value == 'mark_all_read') {
|
||||
await emailRepo.markAllAsRead(
|
||||
widget.accountId,
|
||||
widget.mailboxPath,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: 'mark_all_read',
|
||||
child: Text('Mark all as read'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
|
||||
+1
-3
@@ -86,9 +86,7 @@ Files: `lib/ui/screens/search_screen.dart`, `lib/data/db/database.dart`.
|
||||
|
||||
### U4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/28
|
||||
|
||||
### U5 🟡 Accessible swipe actions on email list items
|
||||
Delete and Move are hidden behind long-press or detail-screen menus. Add leading/trailing swipe actions on the `EmailListScreen` tile (archive / delete) matching Material 3 patterns.
|
||||
Files: `lib/ui/screens/email_list_screen.dart`.
|
||||
### U5 — Already implemented (Dismissible archive/delete swipes with undo, found in email_list_screen.dart)
|
||||
|
||||
### U6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/29
|
||||
|
||||
|
||||
+42
-12
@@ -4,6 +4,7 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import google_auth_httplib2
|
||||
import httplib2
|
||||
@@ -15,6 +16,14 @@ PACKAGE_NAME = "de.sharedinbox.mua"
|
||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||
TRACK = "internal"
|
||||
_TIMEOUT = 300 # seconds — AAB uploads can be large
|
||||
_MAX_UPLOAD_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _make_service(creds):
|
||||
authorized_http = google_auth_httplib2.AuthorizedHttp(
|
||||
creds, http=httplib2.Http(timeout=_TIMEOUT)
|
||||
)
|
||||
return build("androidpublisher", "v3", http=authorized_http)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -32,22 +41,43 @@ def main():
|
||||
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
||||
)
|
||||
|
||||
authorized_http = google_auth_httplib2.AuthorizedHttp(
|
||||
creds, http=httplib2.Http(timeout=_TIMEOUT)
|
||||
)
|
||||
service = build("androidpublisher", "v3", http=authorized_http)
|
||||
service = _make_service(creds)
|
||||
|
||||
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
|
||||
edit_id = edit["id"]
|
||||
|
||||
media = MediaFileUpload(AAB_PATH, mimetype="application/octet-stream", resumable=True)
|
||||
bundle = (
|
||||
service.edits()
|
||||
.bundles()
|
||||
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
|
||||
.execute(num_retries=3)
|
||||
)
|
||||
version_code = bundle["versionCode"]
|
||||
# The resumable upload can fail with RedirectMissingLocation on transient
|
||||
# network hiccups. Retry the upload (with a fresh MediaFileUpload each
|
||||
# time) using exponential backoff before giving up.
|
||||
version_code = None
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
|
||||
try:
|
||||
media = MediaFileUpload(
|
||||
AAB_PATH, mimetype="application/octet-stream", resumable=True
|
||||
)
|
||||
bundle = (
|
||||
service.edits()
|
||||
.bundles()
|
||||
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
|
||||
.execute(num_retries=3)
|
||||
)
|
||||
version_code = bundle["versionCode"]
|
||||
break
|
||||
except httplib2.error.RedirectMissingLocation as exc:
|
||||
last_exc = exc
|
||||
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
|
||||
delay = 10 * (2 ** attempt)
|
||||
print(
|
||||
f"Upload attempt {attempt + 1} failed (redirect error), "
|
||||
f"retrying in {delay}s…"
|
||||
)
|
||||
time.sleep(delay)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
|
||||
) from last_exc
|
||||
|
||||
print(f"Uploaded AAB, version code: {version_code}")
|
||||
|
||||
service.edits().tracks().update(
|
||||
|
||||
@@ -140,6 +140,9 @@ class _FakeEmails implements EmailRepository {
|
||||
@override
|
||||
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
||||
|
||||
@override
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||
|
||||
@override
|
||||
Future<void> moveEmail(String id, String dest) async {}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
|
||||
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import 'db_test_helper.dart';
|
||||
|
||||
// ── Contract ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Verifies the [AccountRepository] interface contract.
|
||||
///
|
||||
/// Subclass this and override [makeRepo] to run the same suite against any
|
||||
/// concrete implementation.
|
||||
abstract class AccountRepositoryContract {
|
||||
AccountRepository makeRepo();
|
||||
|
||||
static const _a = Account(
|
||||
id: 'c-1',
|
||||
displayName: 'Contract',
|
||||
email: 'c@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
smtpHost: 'smtp.example.com',
|
||||
);
|
||||
|
||||
void run() {
|
||||
test('observeAccounts starts empty', () async {
|
||||
final repo = makeRepo();
|
||||
expect(await repo.observeAccounts().first, isEmpty);
|
||||
});
|
||||
|
||||
test('addAccount makes account visible via observeAccounts', () async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
final list = await repo.observeAccounts().first;
|
||||
expect(list, hasLength(1));
|
||||
expect(list.first.id, _a.id);
|
||||
});
|
||||
|
||||
test('getAccount returns null for unknown id', () async {
|
||||
final repo = makeRepo();
|
||||
expect(await repo.getAccount('no-such'), isNull);
|
||||
});
|
||||
|
||||
test('getAccount returns added account', () async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
final a = await repo.getAccount(_a.id);
|
||||
expect(a, isNotNull);
|
||||
expect(a!.email, _a.email);
|
||||
});
|
||||
|
||||
test('getPassword returns stored password', () async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'secret123');
|
||||
expect(await repo.getPassword(_a.id), 'secret123');
|
||||
});
|
||||
|
||||
test('updateAccount reflects changes in observeAccounts', () async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
final updated = _a.copyWith(displayName: 'Updated');
|
||||
await repo.updateAccount(updated);
|
||||
final list = await repo.observeAccounts().first;
|
||||
expect(list.first.displayName, 'Updated');
|
||||
});
|
||||
|
||||
test('updateAccount with password updates stored password', () async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'old');
|
||||
await repo.updateAccount(_a, password: 'new');
|
||||
expect(await repo.getPassword(_a.id), 'new');
|
||||
});
|
||||
|
||||
test('removeAccount makes account disappear from observeAccounts',
|
||||
() async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
await repo.removeAccount(_a.id);
|
||||
expect(await repo.observeAccounts().first, isEmpty);
|
||||
});
|
||||
|
||||
test('getAccount returns null after removeAccount', () async {
|
||||
final repo = makeRepo();
|
||||
await repo.addAccount(_a, 'pw');
|
||||
await repo.removeAccount(_a.id);
|
||||
expect(await repo.getAccount(_a.id), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Impl under test ───────────────────────────────────────────────────────────
|
||||
|
||||
class _AccountRepositoryImplContract extends AccountRepositoryContract {
|
||||
@override
|
||||
AccountRepository makeRepo() =>
|
||||
AccountRepositoryImpl(openTestDatabase(), MapSecureStorage());
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(configureSqliteForTests);
|
||||
|
||||
group('AccountRepositoryImpl satisfies AccountRepository contract', () {
|
||||
_AccountRepositoryImplContract().run();
|
||||
});
|
||||
}
|
||||
@@ -61,6 +61,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
@override
|
||||
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
||||
@override
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||
@override
|
||||
Future<void> moveEmail(String id, String dest) async {}
|
||||
|
||||
@override
|
||||
|
||||
@@ -215,9 +215,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.Email>> observeEmails(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
String? accountId,
|
||||
String? mailboxPath, {
|
||||
int? limit = 50,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -233,9 +233,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
String? accountId,
|
||||
String? mailboxPath, {
|
||||
int? limit = 50,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -337,6 +337,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> markAllAsRead(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#markAllAsRead,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> moveEmail(
|
||||
String? emailId,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
|
||||
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import 'db_test_helper.dart';
|
||||
|
||||
// ── Contract ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Verifies the observable / local-state portion of the [EmailRepository]
|
||||
/// interface contract.
|
||||
///
|
||||
/// Network-dependent methods (syncEmails, sendEmail, etc.) are intentionally
|
||||
/// excluded — they are covered by the concrete impl tests.
|
||||
abstract class EmailRepositoryContract {
|
||||
static const _account = Account(
|
||||
id: 'er-acc',
|
||||
displayName: 'Contract',
|
||||
email: 'er@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
smtpHost: 'smtp.example.com',
|
||||
);
|
||||
|
||||
/// Return a fresh [EmailRepository] with [_account] already persisted.
|
||||
Future<EmailRepository> makeRepo();
|
||||
|
||||
/// Insert a raw email row so tests can assert on observable state without
|
||||
/// triggering a network sync.
|
||||
Future<void> insertEmail(
|
||||
EmailRepository repo, {
|
||||
required String id,
|
||||
required String mailboxPath,
|
||||
bool isSeen = true,
|
||||
bool isFlagged = false,
|
||||
DateTime? receivedAt,
|
||||
});
|
||||
|
||||
void run() {
|
||||
test('observeEmails starts empty', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(
|
||||
await repo.observeEmails(_account.id, 'INBOX').first,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('observeEmails emits inserted email', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
||||
final emails = await repo.observeEmails(_account.id, 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.id, 'er-acc:1');
|
||||
});
|
||||
|
||||
test('observeEmails only returns emails for the given mailbox', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
|
||||
expect(
|
||||
await repo.observeEmails(_account.id, 'Sent').first,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('observeEmails orders by receivedAt descending', () async {
|
||||
final repo = await makeRepo();
|
||||
final older = DateTime(2024);
|
||||
final newer = DateTime(2024, 6);
|
||||
await insertEmail(
|
||||
repo,
|
||||
id: 'er-acc:1',
|
||||
mailboxPath: 'INBOX',
|
||||
receivedAt: older,
|
||||
);
|
||||
await insertEmail(
|
||||
repo,
|
||||
id: 'er-acc:2',
|
||||
mailboxPath: 'INBOX',
|
||||
receivedAt: newer,
|
||||
);
|
||||
final emails = await repo.observeEmails(_account.id, 'INBOX').first;
|
||||
expect(emails.first.id, 'er-acc:2');
|
||||
expect(emails.last.id, 'er-acc:1');
|
||||
});
|
||||
|
||||
test('getEmail returns null for unknown id', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(await repo.getEmail('no-such'), isNull);
|
||||
});
|
||||
|
||||
test('getEmail returns inserted email', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertEmail(repo, id: 'er-acc:7', mailboxPath: 'INBOX');
|
||||
final email = await repo.getEmail('er-acc:7');
|
||||
expect(email, isNotNull);
|
||||
expect(email!.accountId, _account.id);
|
||||
});
|
||||
|
||||
test('setFlag seen updates isSeen', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertEmail(
|
||||
repo,
|
||||
id: 'er-acc:10',
|
||||
mailboxPath: 'INBOX',
|
||||
isSeen: false,
|
||||
);
|
||||
await repo.setFlag('er-acc:10', seen: true);
|
||||
final email = await repo.getEmail('er-acc:10');
|
||||
expect(email!.isSeen, isTrue);
|
||||
});
|
||||
|
||||
test('setFlag flagged updates isFlagged', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertEmail(
|
||||
repo,
|
||||
id: 'er-acc:11',
|
||||
mailboxPath: 'INBOX',
|
||||
);
|
||||
await repo.setFlag('er-acc:11', flagged: true);
|
||||
final email = await repo.getEmail('er-acc:11');
|
||||
expect(email!.isFlagged, isTrue);
|
||||
});
|
||||
|
||||
test('markAllAsRead marks every unread email in the mailbox', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertEmail(
|
||||
repo,
|
||||
id: 'er-acc:20',
|
||||
mailboxPath: 'INBOX',
|
||||
isSeen: false,
|
||||
);
|
||||
await insertEmail(
|
||||
repo,
|
||||
id: 'er-acc:21',
|
||||
mailboxPath: 'INBOX',
|
||||
isSeen: false,
|
||||
);
|
||||
await insertEmail(
|
||||
repo,
|
||||
id: 'er-acc:22',
|
||||
mailboxPath: 'Sent',
|
||||
isSeen: false,
|
||||
);
|
||||
|
||||
await repo.markAllAsRead(_account.id, 'INBOX');
|
||||
|
||||
expect((await repo.getEmail('er-acc:20'))!.isSeen, isTrue);
|
||||
expect((await repo.getEmail('er-acc:21'))!.isSeen, isTrue);
|
||||
// Email in a different mailbox should be untouched.
|
||||
expect((await repo.getEmail('er-acc:22'))!.isSeen, isFalse);
|
||||
});
|
||||
|
||||
test('observeThreads starts empty', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(
|
||||
await repo.observeThreads(_account.id, 'INBOX').first,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Impl under test ───────────────────────────────────────────────────────────
|
||||
|
||||
class _EmailRepositoryImplContract extends EmailRepositoryContract {
|
||||
static const _account = EmailRepositoryContract._account;
|
||||
|
||||
late AppDatabase _db;
|
||||
late AccountRepositoryImpl _accountRepo;
|
||||
|
||||
@override
|
||||
Future<EmailRepository> makeRepo() async {
|
||||
_db = openTestDatabase();
|
||||
_accountRepo = AccountRepositoryImpl(_db, MapSecureStorage());
|
||||
await _accountRepo.addAccount(_account, 'pw');
|
||||
return EmailRepositoryImpl(
|
||||
_db,
|
||||
_accountRepo,
|
||||
imapConnect: (_, __, ___) => Future<imap.ImapClient>.error(
|
||||
UnsupportedError('no IMAP in unit tests'),
|
||||
),
|
||||
smtpConnect: (_, __, ___) => Future<imap.SmtpClient>.error(
|
||||
UnsupportedError('no SMTP in unit tests'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertEmail(
|
||||
EmailRepository repo, {
|
||||
required String id,
|
||||
required String mailboxPath,
|
||||
bool isSeen = true,
|
||||
bool isFlagged = false,
|
||||
DateTime? receivedAt,
|
||||
}) async {
|
||||
await _db.into(_db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
mailboxPath: mailboxPath,
|
||||
uid: int.parse(id.split(':').last),
|
||||
receivedAt: receivedAt ?? DateTime.now(),
|
||||
isSeen: Value(isSeen),
|
||||
isFlagged: Value(isFlagged),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(configureSqliteForTests);
|
||||
|
||||
group('EmailRepositoryImpl satisfies EmailRepository contract', () {
|
||||
_EmailRepositoryImplContract().run();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
|
||||
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import 'db_test_helper.dart';
|
||||
|
||||
// ── Contract ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Verifies the [MailboxRepository] interface contract.
|
||||
///
|
||||
/// Tests cover only the locally-observable part of the interface
|
||||
/// (observe / find) since sync methods require live IMAP/JMAP servers.
|
||||
abstract class MailboxRepositoryContract {
|
||||
static const _account = Account(
|
||||
id: 'm-acc',
|
||||
displayName: 'Contract',
|
||||
email: 'm@example.com',
|
||||
imapHost: 'imap.example.com',
|
||||
smtpHost: 'smtp.example.com',
|
||||
);
|
||||
|
||||
/// Return a fresh [MailboxRepository] with [_account] already persisted.
|
||||
Future<MailboxRepository> makeRepo();
|
||||
|
||||
/// Insert a mailbox row into the backing store so tests can verify
|
||||
/// observeMailboxes without triggering a network sync.
|
||||
Future<void> insertMailbox(
|
||||
MailboxRepository repo, {
|
||||
required String id,
|
||||
required String path,
|
||||
String? role,
|
||||
int unread = 0,
|
||||
int total = 0,
|
||||
});
|
||||
|
||||
void run() {
|
||||
test('observeMailboxes starts empty', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(await repo.observeMailboxes(_account.id).first, isEmpty);
|
||||
});
|
||||
|
||||
test('observeMailboxes emits inserted rows ordered by path', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertMailbox(repo, id: 'z', path: 'Z');
|
||||
await insertMailbox(repo, id: 'a', path: 'A');
|
||||
final boxes = await repo.observeMailboxes(_account.id).first;
|
||||
expect(boxes.map((b) => b.path), ['A', 'Z']);
|
||||
});
|
||||
|
||||
test('observeMailboxes only returns rows for the given account', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertMailbox(repo, id: 'mb1', path: 'INBOX');
|
||||
expect(await repo.observeMailboxes('other-acc').first, isEmpty);
|
||||
});
|
||||
|
||||
test('findMailboxByRole returns null when no match', () async {
|
||||
final repo = await makeRepo();
|
||||
expect(
|
||||
await repo.findMailboxByRole(_account.id, 'archive'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('findMailboxByRole returns the matching mailbox', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertMailbox(repo, id: 'arch', path: 'Archive', role: 'archive');
|
||||
final box = await repo.findMailboxByRole(_account.id, 'archive');
|
||||
expect(box, isNotNull);
|
||||
expect(box!.role, 'archive');
|
||||
});
|
||||
|
||||
test('clearForResync removes all mailboxes for the account', () async {
|
||||
final repo = await makeRepo();
|
||||
await insertMailbox(repo, id: 'mb', path: 'INBOX');
|
||||
await repo.clearForResync(_account.id);
|
||||
expect(await repo.observeMailboxes(_account.id).first, isEmpty);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Impl under test ───────────────────────────────────────────────────────────
|
||||
|
||||
class _MailboxRepositoryImplContract extends MailboxRepositoryContract {
|
||||
static const _account = MailboxRepositoryContract._account;
|
||||
|
||||
late AppDatabase _db;
|
||||
late AccountRepositoryImpl _accountRepo;
|
||||
|
||||
@override
|
||||
Future<MailboxRepository> makeRepo() async {
|
||||
_db = openTestDatabase();
|
||||
_accountRepo = AccountRepositoryImpl(_db, MapSecureStorage());
|
||||
await _accountRepo.addAccount(_account, 'pw');
|
||||
return MailboxRepositoryImpl(
|
||||
_db,
|
||||
_accountRepo,
|
||||
imapConnect: (_, __, ___) =>
|
||||
Future.error(UnsupportedError('no IMAP in unit tests')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertMailbox(
|
||||
MailboxRepository repo, {
|
||||
required String id,
|
||||
required String path,
|
||||
String? role,
|
||||
int unread = 0,
|
||||
int total = 0,
|
||||
}) async {
|
||||
await _db.into(_db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
path: path,
|
||||
name: path.split('/').last,
|
||||
unreadCount: Value(unread),
|
||||
totalCount: Value(total),
|
||||
role: Value(role),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(configureSqliteForTests);
|
||||
|
||||
group('MailboxRepositoryImpl satisfies MailboxRepository contract', () {
|
||||
_MailboxRepositoryImplContract().run();
|
||||
});
|
||||
}
|
||||
@@ -103,6 +103,8 @@ class _CountingEmails implements EmailRepository {
|
||||
@override
|
||||
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
||||
@override
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||
@override
|
||||
Future<void> moveEmail(String id, String dest) async {}
|
||||
@override
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
|
||||
@@ -75,9 +75,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.Email>> observeEmails(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
String? accountId,
|
||||
String? mailboxPath, {
|
||||
int? limit = 50,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -93,9 +93,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
String? accountId,
|
||||
String? mailboxPath, {
|
||||
int? limit = 50,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -197,6 +197,23 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> markAllAsRead(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#markAllAsRead,
|
||||
[
|
||||
accountId,
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> moveEmail(
|
||||
String? emailId,
|
||||
|
||||
@@ -213,6 +213,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
|
||||
@override
|
||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
|
||||
@override
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||
|
||||
@override
|
||||
Future<void> moveEmail(String emailId, String destMailboxPath) async {}
|
||||
|
||||
Reference in New Issue
Block a user