From c7a121e3864e796e4811e1ae08775cdb21fdd40d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Sat, 18 Apr 2026 12:05:20 +0200 Subject: [PATCH] stalwart-dev/integration_ui_test.sh working! great! --- .github/workflows/ci.yml | 33 +++ flake.nix | 3 + integration_test/app_e2e_test.dart | 195 ++++++++++++++++++ lib/data/imap/imap_client_factory.dart | 17 +- .../repositories/email_repository_impl.dart | 36 +++- lib/di.dart | 7 +- lib/main.dart | 4 +- pubspec.yaml | 2 + stalwart-dev/config.toml | 4 +- stalwart-dev/integration_ui_test.sh | 91 ++++++++ stalwart-dev/test.sh | 7 +- test/integration/imap_sync_test.dart | 4 +- test/unit/email_repository_impl_test.dart | 5 +- test/unit/fake_imap.dart | 27 +++ 14 files changed, 411 insertions(+), 24 deletions(-) create mode 100644 integration_test/app_e2e_test.dart create mode 100755 stalwart-dev/integration_ui_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38c16a5..0ae9f64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,39 @@ jobs: stalwart-dev/test.sh " + integration-ui: + name: UI Integration tests (Stalwart + Xvfb) + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - uses: DeterminateSystems/nix-installer-action@v14 + + - uses: DeterminateSystems/magic-nix-cache-action@v8 + + - name: Install Flutter Linux build dependencies + run: | + sudo apt-get update -q + sudo apt-get install -y --no-install-recommends \ + libgtk-3-dev pkg-config cmake ninja-build clang \ + libsecret-1-dev + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.41.6" + channel: stable + cache: true + + - name: Run UI integration tests + run: | + nix develop --command bash -c " + flutter pub get && + flutter pub run build_runner build --delete-conflicting-outputs && + stalwart-dev/integration_ui_test.sh + " + build-linux: name: Build Linux desktop runs-on: ubuntu-latest diff --git a/flake.nix b/flake.nix index ab9104c..55b13b8 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,9 @@ # Local IMAP/SMTP dev server for integration tests stalwart-mail + # Headless display for UI integration tests + xvfb-run # wraps Xvfb; xvfb-run --auto-servernum ... + # Utilities git curl diff --git a/integration_test/app_e2e_test.dart b/integration_test/app_e2e_test.dart new file mode 100644 index 0000000..33be382 --- /dev/null +++ b/integration_test/app_e2e_test.dart @@ -0,0 +1,195 @@ +// E2E integration tests — requires a running Stalwart instance and a display. +// Run via: stalwart-dev/integration_ui_test.sh +// +// Environment variables (set by the runner script): +// STALWART_IMAP_HOST, STALWART_IMAP_PORT +// STALWART_SMTP_HOST, STALWART_SMTP_PORT +// STALWART_USER_B / STALWART_PASS_B (alice@localhost) + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:sharedinbox/core/storage/secure_storage.dart'; +import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/main.dart' as app; + +/// In-memory drop-in for SecureStorage — no D-Bus or keyring daemon required. +class _InMemorySecureStorage implements SecureStorage { + final _store = {}; + + @override + Future write({required String key, required String? value}) async { + if (value == null) { + _store.remove(key); + } else { + _store[key] = value; + } + } + + @override + Future read({required String key}) async => _store[key]; + + @override + Future delete({required String key}) async => _store.remove(key); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late String imapHost; + late int imapPort; + late String smtpHost; + late int smtpPort; + late String userEmail; + late String userPass; + + setUpAll(() { + imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1'; + imapPort = int.parse(Platform.environment['STALWART_IMAP_PORT'] ?? '1430'); + smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1'; + smtpPort = int.parse(Platform.environment['STALWART_SMTP_PORT'] ?? '1025'); + userEmail = Platform.environment['STALWART_USER_B'] ?? 'alice@localhost'; + userPass = Platform.environment['STALWART_PASS_B'] ?? 'secret'; + }); + + testWidgets( + 'E2E: add account, send mail to self, verify sent/inbox, search', + (tester) async { + // The Flutter Linux test runner defaults to a 1×1 window; give it a + // real size so widgets are laid out and hittable. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + app.main(overrides: [ + secureStorageProvider.overrideWithValue(_InMemorySecureStorage()), + ]); + await tester.pumpAndSettle(); + + // ── Add account ──────────────────────────────────────────────────────── + expect(find.text('No accounts yet.'), findsOneWidget); + + await tester.tap(find.widgetWithIcon(FloatingActionButton, Icons.add)); + await tester.pumpAndSettle(); + + expect(find.text('Add account'), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Display name'), 'Alice'); + await tester.enterText( + find.widgetWithText(TextFormField, 'Email address'), userEmail); + await tester.enterText( + find.widgetWithText(TextFormField, 'Password'), userPass); + await tester.enterText( + find.widgetWithText(TextFormField, 'IMAP host'), imapHost); + + // The form has two "Port" fields: index 0 = IMAP, index 1 = SMTP. + final imapPortField = + find.widgetWithText(TextFormField, 'Port').at(0); + await tester.ensureVisible(imapPortField); + await tester.enterText(imapPortField, imapPort.toString()); + + // IMAP SSL defaults to on — turn it off for the plaintext dev server. + final imapSslSwitch = + find.widgetWithText(SwitchListTile, 'SSL/TLS').at(0); + await tester.ensureVisible(imapSslSwitch); + await tester.tap(imapSslSwitch); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'SMTP host'), smtpHost); + + final smtpPortField = + find.widgetWithText(TextFormField, 'Port').at(1); + await tester.ensureVisible(smtpPortField); + await tester.enterText(smtpPortField, smtpPort.toString()); + + final saveButton = find.widgetWithText(FilledButton, 'Save'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + // Back at account list. + expect(find.text('Alice'), findsOneWidget); + expect(find.text(userEmail), findsOneWidget); + + // ── Navigate to mailboxes ────────────────────────────────────────────── + await tester.tap(find.text('Alice')); + // Give the background sync time to populate mailboxes from IMAP. + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + expect(find.text('INBOX'), findsOneWidget); + + // ── Compose and send email to self ───────────────────────────────────── + await tester.tap(find.text('INBOX')); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.edit)); + await tester.pumpAndSettle(); + + final subject = 'E2E-${DateTime.now().millisecondsSinceEpoch}'; + + await tester.enterText( + find.widgetWithText(TextFormField, 'To'), userEmail); + await tester.enterText( + find.widgetWithText(TextFormField, 'Subject'), subject); + + final bodyField = find.widgetWithText(TextFormField, 'Body'); + await tester.ensureVisible(bodyField); + await tester.enterText(bodyField, 'Hello from integration test!'); + + await tester.tap(find.byIcon(Icons.send)); + // Wait for SMTP send + IMAP APPEND to complete. + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + // ComposeScreen pops back to EmailListScreen (INBOX) after send. + + // ── Check Sent folder ────────────────────────────────────────────────── + // Go back to MailboxListScreen. + await tester.pageBack(); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Sent')); + await tester.pumpAndSettle(); + + // Sync Sent folder to fetch the appended message. + await tester.tap(find.byIcon(Icons.sync)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + expect(find.text(subject), findsOneWidget); + + // ── Check Inbox ──────────────────────────────────────────────────────── + await tester.pageBack(); // Sent EmailList → MailboxList + await tester.pumpAndSettle(); + + await tester.tap(find.text('INBOX')); + await tester.pumpAndSettle(); + + // Sync INBOX — Stalwart delivers to self near-instantly. + await tester.tap(find.byIcon(Icons.sync)); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + + expect(find.text(subject), findsOneWidget); + + // ── Search ───────────────────────────────────────────────────────────── + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + // Search by the 'E2E-' prefix — should match the message we just sent. + await tester.enterText(find.byType(TextField), 'E2E-'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + + expect(find.text(subject), findsOneWidget); + }, + ); +} diff --git a/lib/data/imap/imap_client_factory.dart b/lib/data/imap/imap_client_factory.dart index b24d524..1284069 100644 --- a/lib/data/imap/imap_client_factory.dart +++ b/lib/data/imap/imap_client_factory.dart @@ -1,3 +1,5 @@ +import 'dart:io' show HandshakeException; + import 'package:enough_mail/enough_mail.dart'; import '../../core/models/account.dart'; @@ -25,7 +27,7 @@ Future connectSmtp(Account account, String password) async { final clientDomain = atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost; - final client = SmtpClient(clientDomain); + var client = SmtpClient(clientDomain); await client.connectToServer( account.smtpHost, account.smtpPort, @@ -33,9 +35,20 @@ Future connectSmtp(Account account, String password) async { ); await client.ehlo(); if (!account.smtpSsl) { - // Opportunistic TLS on submission port (587) + // Opportunistic TLS on submission port (587). try { await client.startTls(); + } on HandshakeException catch (e) { + // TLS handshake failure (e.g. self-signed cert) breaks the socket. + // Reconnect plaintext so authenticate() can still proceed. + log('STARTTLS handshake failed on ${account.smtpHost}: $e — reconnecting without TLS'); + client = SmtpClient(clientDomain); + await client.connectToServer( + account.smtpHost, + account.smtpPort, + isSecure: false, + ); + await client.ehlo(); } catch (e) { log('STARTTLS not available on ${account.smtpHost}: $e — continuing without TLS'); } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 2eedb6f..050ba21 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -234,22 +234,36 @@ class EmailRepositoryImpl implements EmailRepository { Future sendEmail(String accountId, model.EmailDraft draft) async { final account = (await _accounts.getAccount(accountId))!; final password = await _accounts.getPassword(accountId); + final builder = imap.MessageBuilder() + ..from = [imap.MailAddress(draft.from.name, draft.from.email)] + ..to = draft.to.map((a) => imap.MailAddress(a.name, a.email)).toList() + ..cc = draft.cc.map((a) => imap.MailAddress(a.name, a.email)).toList() + ..subject = draft.subject + ..text = draft.body; + final mimeMessage = builder.buildMimeMessage(); final smtpClient = await _smtpConnect(account, password); try { - final builder = imap.MessageBuilder() - ..from = [imap.MailAddress(draft.from.name, draft.from.email)] - ..to = draft.to - .map((a) => imap.MailAddress(a.name, a.email)) - .toList() - ..cc = draft.cc - .map((a) => imap.MailAddress(a.name, a.email)) - .toList() - ..subject = draft.subject - ..text = draft.body; - await smtpClient.sendMessage(builder.buildMimeMessage()); + await smtpClient.sendMessage(mimeMessage); } finally { await smtpClient.quit(); } + // Save a copy to the Sent folder via IMAP APPEND. + // Create the folder first — many servers don't pre-create it. + final imapClient = await _imapConnect(account, password); + try { + try { + await imapClient.createMailbox('Sent'); + } catch (_) { + // Already exists — that's fine. + } + await imapClient.appendMessage( + mimeMessage, + targetMailboxPath: 'Sent', + flags: [r'\Seen'], + ); + } finally { + await imapClient.logout(); + } } @override diff --git a/lib/di.dart b/lib/di.dart index 6324638..e82f0ca 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/repositories/account_repository.dart'; import 'core/repositories/email_repository.dart'; import 'core/repositories/mailbox_repository.dart'; +import 'core/storage/secure_storage.dart'; import 'core/sync/account_sync_manager.dart'; import 'data/db/database.dart'; import 'data/repositories/account_repository_impl.dart'; @@ -16,10 +17,14 @@ final dbProvider = Provider((ref) { return db; }); +final secureStorageProvider = Provider((ref) { + return const FlutterSecureStorageImpl(); +}); + final accountRepositoryProvider = Provider((ref) { return AccountRepositoryImpl( ref.watch(dbProvider), - const FlutterSecureStorageImpl(), + ref.watch(secureStorageProvider), ); }); diff --git a/lib/main.dart b/lib/main.dart index 0e0362a..7954f1e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,8 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'di.dart'; import 'ui/router.dart'; -void main() { - runApp(const ProviderScope(child: SharedInboxApp())); +void main({List overrides = const []}) { + runApp(ProviderScope(overrides: overrides, child: const SharedInboxApp())); } class SharedInboxApp extends ConsumerStatefulWidget { diff --git a/pubspec.yaml b/pubspec.yaml index 6890696..bba3c8e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^4.0.0 drift_dev: ^2.20.3 build_runner: ^2.4.13 diff --git a/stalwart-dev/config.toml b/stalwart-dev/config.toml index 4f27e00..08793df 100644 --- a/stalwart-dev/config.toml +++ b/stalwart-dev/config.toml @@ -48,13 +48,13 @@ type = "memory" [[directory."memory".principals]] class = "individual" -name = "alice" +name = "alice@localhost" secret = "secret" email = ["alice@localhost"] [[directory."memory".principals]] class = "individual" -name = "bob" +name = "bob@localhost" secret = "secret" email = ["bob@localhost"] diff --git a/stalwart-dev/integration_ui_test.sh b/stalwart-dev/integration_ui_test.sh new file mode 100755 index 0000000..28e923b --- /dev/null +++ b/stalwart-dev/integration_ui_test.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Starts Stalwart on random ports, then runs Flutter UI integration tests inside +# a virtual X server (Xvfb). Works on a local desktop and in headless CI. +# No D-Bus or keyring daemon is required — tests inject an in-memory SecureStorage. +# +# Run inside nix develop: stalwart-dev/integration_ui_test.sh +set -Eeuo pipefail + +export STALWART_USER_B="${STALWART_USER_B:-alice@localhost}" +export STALWART_PASS_B="${STALWART_PASS_B:-secret}" +export STALWART_USER_C="${STALWART_USER_C:-bob@localhost}" +export STALWART_PASS_C="${STALWART_PASS_C:-secret}" +export STALWART_RANDOM_PORTS=1 +STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)" +export STALWART_TMPDIR + +# Isolate the app database: fresh HOME → fresh path_provider directory. +TEST_HOME="$(mktemp -d /tmp/sharedinbox-test-home-XXXXXX)" + +cleanup() { + kill "${STALWART_PID:-}" 2>/dev/null || true + wait "${STALWART_PID:-}" 2>/dev/null || true + rm -rf "$TEST_HOME" +} +trap cleanup EXIT + +command -v stalwart >/dev/null || { + echo "stalwart not in PATH." + echo "Run inside the nix dev shell:" + echo " nix develop --command stalwart-dev/integration_ui_test.sh" + exit 1 +} +command -v xvfb-run >/dev/null || { + echo "xvfb-run not in PATH." + echo "Run inside the nix dev shell:" + echo " nix develop --command stalwart-dev/integration_ui_test.sh" + exit 1 +} + +# Pre-seed spam-filter version so Stalwart does not fetch it on first boot. +mkdir -p "$STALWART_TMPDIR" +sqlite3 "${STALWART_TMPDIR}/data.sqlite" \ + "CREATE TABLE IF NOT EXISTS s (k BLOB PRIMARY KEY, v BLOB NOT NULL); + INSERT OR REPLACE INTO s VALUES ('version.spam-filter', 'dev');" 2>/dev/null || true + +LOGFILE="${STALWART_TMPDIR}/stalwart.log" +rm -f "$LOGFILE" + +"$(dirname "$0")/start" >"$LOGFILE" 2>&1 & +STALWART_PID=$! + +# Wait until Stalwart is accepting connections (up to 10 s). +for _i in $(seq 1 20); do + # shellcheck source=/dev/null + [ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env" + grep -E "Configuration build error|Build error for key|already in use" "$LOGFILE" >/dev/null 2>&1 && { + cat "$LOGFILE"; echo "Stalwart reported a startup error"; exit 1 + } + kill -0 "$STALWART_PID" 2>/dev/null || { + cat "$LOGFILE"; echo "Stalwart process died unexpectedly"; exit 1 + } + if [ -n "${STALWART_URL:-}" ] && \ + curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" 2>/dev/null; then + break + fi + sleep 0.5 +done + +[ -n "${STALWART_URL:-}" ] || { cat "$LOGFILE"; echo "Stalwart did not publish its chosen ports"; exit 1; } +curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" || { + cat "$LOGFILE"; echo "Stalwart did not become ready"; exit 1 +} + +echo "Stalwart ready — IMAP=:${STALWART_IMAP_PORT:-?} SMTP=:${STALWART_SMTP_PORT:-?}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export STALWART_IMAP_HOST="127.0.0.1" +export STALWART_SMTP_HOST="127.0.0.1" +export HOME="$TEST_HOME" + +START=$(date +%s) + +# xvfb-run provides a virtual framebuffer so the Flutter Linux runner has a +# display without requiring a real desktop session. No D-Bus or keyring daemon +# is needed because the integration tests inject an in-memory SecureStorage. +xvfb-run --auto-servernum fvm flutter test integration_test/ -d linux + +END=$(date +%s) +echo "ui-integration: $((END - START))s" diff --git a/stalwart-dev/test.sh b/stalwart-dev/test.sh index 90deb8a..f53388a 100755 --- a/stalwart-dev/test.sh +++ b/stalwart-dev/test.sh @@ -4,9 +4,9 @@ set -Eeuo pipefail trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR -export STALWART_USER_B="${STALWART_USER_B:-alice}" +export STALWART_USER_B="${STALWART_USER_B:-alice@localhost}" export STALWART_PASS_B="${STALWART_PASS_B:-secret}" -export STALWART_USER_C="${STALWART_USER_C:-bob}" +export STALWART_USER_C="${STALWART_USER_C:-bob@localhost}" export STALWART_PASS_C="${STALWART_PASS_C:-secret}" export STALWART_RANDOM_PORTS=1 STALWART_TMPDIR="$(mktemp -d /tmp/stalwart-dev-XXXXXX)" @@ -32,6 +32,7 @@ trap 'kill "$STALWART_PID" 2>/dev/null || true; wait "$STALWART_PID" 2>/dev/null # Wait until Stalwart is accepting connections (up to 10 s). for _i in $(seq 1 20); do + # shellcheck source=/dev/null [ -f "${STALWART_TMPDIR}/ports.env" ] && . "${STALWART_TMPDIR}/ports.env" grep -E "Configuration build error|Build error for key|already in use" "$LOGFILE" >/dev/null 2>&1 && { cat "$LOGFILE"; echo "Stalwart reported a startup error"; exit 1 @@ -51,7 +52,7 @@ curl -s --max-time 1 -o /dev/null "${STALWART_URL}/.well-known/jmap" || { cat "$LOGFILE"; echo "Stalwart did not become ready"; exit 1 } -echo "Stalwart ready — IMAP=:${STALWART_IMAP_PORT} SMTP=:${STALWART_SMTP_PORT}" +echo "Stalwart ready — IMAP=:${STALWART_IMAP_PORT:-?} SMTP=:${STALWART_SMTP_PORT:-?}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" diff --git a/test/integration/imap_sync_test.dart b/test/integration/imap_sync_test.dart index d9b8041..997fb14 100644 --- a/test/integration/imap_sync_test.dart +++ b/test/integration/imap_sync_test.dart @@ -66,8 +66,8 @@ void main() { await smtpClient.authenticate(userA, passA); final builder = MessageBuilder() - ..from = [MailAddress('Alice', '$userA@localhost')] - ..to = [MailAddress('Bob', '$userB@localhost')] + ..from = [MailAddress('Alice', userA)] + ..to = [MailAddress('Bob', userB)] ..subject = 'Integration test ${DateTime.now().millisecondsSinceEpoch}' ..text = 'Hello from SharedInbox integration test.'; await smtpClient.sendMessage(builder.buildMimeMessage()); diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index cd2ad91..35539ac 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -371,7 +371,7 @@ void main() { expect(r.fakeImap.logoutCalled, isTrue); }); - test('sendEmail calls SMTP sendMessage and quit', () async { + test('sendEmail sends via SMTP and appends copy to Sent folder', () async { final r = _makeReposWithFakes(); await r.accounts.addAccount(_account, 'pw'); @@ -388,6 +388,9 @@ void main() { expect(r.fakeSmtp.messageSent, isTrue); expect(r.fakeSmtp.quitCalled, isTrue); + expect(r.fakeImap.appendCalls, 1); + expect(r.fakeImap.lastAppendMailboxPath, 'Sent'); + expect(r.fakeImap.logoutCalled, isTrue); }); test('searchEmails returns emails matching IMAP search', () async { diff --git a/test/unit/fake_imap.dart b/test/unit/fake_imap.dart index de050d6..bef7c40 100644 --- a/test/unit/fake_imap.dart +++ b/test/unit/fake_imap.dart @@ -17,6 +17,9 @@ class FakeImapClient extends imap.ImapClient { int markDeletedCalls = 0; int expungeCalls = 0; int moveEmailCalls = 0; + int appendCalls = 0; + String? lastAppendMailboxPath; + int createMailboxCalls = 0; @override Future selectMailboxByPath( @@ -107,6 +110,30 @@ class FakeImapClient extends imap.ImapClient { return null; } + @override + Future createMailbox(String path) async { + createMailboxCalls++; + return imap.Mailbox( + encodedName: path, + encodedPath: path, + flags: [], + pathSeparator: '/', + ); + } + + @override + Future appendMessage( + imap.MimeMessage message, { + List? flags, + imap.Mailbox? targetMailbox, + String? targetMailboxPath, + Duration? responseTimeout, + }) async { + appendCalls++; + lastAppendMailboxPath = targetMailboxPath; + return imap.GenericImapResult(); + } + @override Future uidMove( imap.MessageSequence sequence, {