diff --git a/lib/core/models/account.dart b/lib/core/models/account.dart index 73e8d65..9c69e79 100644 --- a/lib/core/models/account.dart +++ b/lib/core/models/account.dart @@ -21,6 +21,11 @@ class Account { /// then to the local part of [email] (the part before '@'). final String username; + /// When true, raw protocol traffic is captured and written to the sync log. + /// Never enable in production — logs contain sensitive data even after + /// credential redaction. + final bool verbose; + const Account({ required this.id, required this.displayName, @@ -34,5 +39,6 @@ class Account { this.smtpPort = 587, this.smtpSsl = false, this.jmapUrl, + this.verbose = false, }); } diff --git a/lib/core/repositories/sync_log_repository.dart b/lib/core/repositories/sync_log_repository.dart index d1c47c7..15efd4a 100644 --- a/lib/core/repositories/sync_log_repository.dart +++ b/lib/core/repositories/sync_log_repository.dart @@ -26,6 +26,7 @@ class SyncLogEntry { required this.startedAt, required this.finishedAt, this.mailboxStats = const [], + this.protocolLog, }); final int id; @@ -40,6 +41,7 @@ class SyncLogEntry { final DateTime startedAt; final DateTime finishedAt; final List mailboxStats; + final String? protocolLog; Duration get duration => finishedAt.difference(startedAt); bool get isOk => result == 'ok'; @@ -59,6 +61,7 @@ abstract class SyncLogRepository { required DateTime startedAt, required DateTime finishedAt, List mailboxStats = const [], + String? protocolLog, }); Stream> observeSyncLogs(String accountId); @@ -81,6 +84,7 @@ class NoOpSyncLogRepository implements SyncLogRepository { required DateTime startedAt, required DateTime finishedAt, List mailboxStats = const [], + String? protocolLog, }) async {} @override diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index f41bce0..43f10fb 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'package:enough_mail/enough_mail.dart' as imap; -import '../../data/imap/imap_client_factory.dart'; +import '../../data/imap/imap_client_factory.dart' + show ImapConnectFn, connectImap, verboseLogKey; import '../models/account.dart'; import '../models/email.dart' show SyncEmailsResult; import '../repositories/account_repository.dart'; @@ -124,7 +125,8 @@ class _AccountSync implements _SyncLoop { while (_running) { final startedAt = DateTime.now(); try { - final stats = await _sync(); + final (_SyncStats stats, String? capturedLog) = + await _runSync(account.verbose); await _syncLog.log( accountId: account.id, success: true, @@ -137,6 +139,7 @@ class _AccountSync implements _SyncLoop { startedAt: startedAt, finishedAt: DateTime.now(), mailboxStats: stats.mailboxStats, + protocolLog: capturedLog, ); await _idle(); _backoffSeconds = 5; @@ -169,6 +172,19 @@ class _AccountSync implements _SyncLoop { } } + Future<(_SyncStats, String?)> _runSync(bool verbose) async { + if (!verbose) return (await _sync(), null); + final buffer = StringBuffer(); + final stats = await runZoned( + _sync, + zoneValues: {verboseLogKey: buffer}, + zoneSpecification: ZoneSpecification( + print: (_, __, ___, line) => buffer.writeln(line), + ), + ); + return (stats, _redactCredentials(buffer.toString())); + } + Future<_SyncStats> _sync() async { final password = await _accounts.getPassword(account.id); final pendingFlushed = @@ -284,7 +300,8 @@ class _JmapAccountSync implements _SyncLoop { while (_running) { final startedAt = DateTime.now(); try { - final stats = await _sync(); + final (_SyncStats stats, String? capturedLog) = + await _runSync(account.verbose); await _syncLog.log( accountId: account.id, success: true, @@ -297,6 +314,7 @@ class _JmapAccountSync implements _SyncLoop { startedAt: startedAt, finishedAt: DateTime.now(), mailboxStats: stats.mailboxStats, + protocolLog: capturedLog, ); _backoffSeconds = 5; await _wait(); @@ -329,6 +347,19 @@ class _JmapAccountSync implements _SyncLoop { } } + Future<(_SyncStats, String?)> _runSync(bool verbose) async { + if (!verbose) return (await _sync(), null); + final buffer = StringBuffer(); + final stats = await runZoned( + _sync, + zoneValues: {verboseLogKey: buffer}, + zoneSpecification: ZoneSpecification( + print: (_, __, ___, line) => buffer.writeln(line), + ), + ); + return (stats, buffer.toString()); + } + Future<_SyncStats> _sync() async { final password = await _accounts.getPassword(account.id); @@ -418,3 +449,19 @@ class _SyncStats { final int bytesTransferred; final List mailboxStats; } + +/// Replaces credentials in a captured IMAP protocol log. +/// +/// Redacts the password argument from LOGIN commands and the base64 payload +/// from AUTHENTICATE commands. Other lines pass through unchanged. +String _redactCredentials(String log) { + return log + .replaceAllMapped( + RegExp(r'(LOGIN\s+\S+\s+)\S+', caseSensitive: false), + (m) => '${m.group(1)}[REDACTED]', + ) + .replaceAllMapped( + RegExp(r'(AUTHENTICATE\s+\w+\s+)\S+', caseSensitive: false), + (m) => '${m.group(1)}[REDACTED]', + ); +} diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index f146615..324b121 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -24,6 +24,8 @@ class Accounts extends Table { TextColumn get jmapUrl => text().nullable()(); // Added in schema v3: TextColumn get username => text().withDefault(const Constant(''))(); + // Added in schema v13: + BoolColumn get verbose => boolean().withDefault(const Constant(false))(); @override Set get primaryKey => {id}; @@ -137,6 +139,8 @@ class SyncLogs extends Table { IntColumn get bytesTransferred => integer().withDefault(const Constant(0))(); DateTimeColumn get startedAt => dateTime()(); DateTimeColumn get finishedAt => dateTime()(); + // Added in schema v13: raw protocol log when account.verbose == true. + TextColumn get protocolLog => text().nullable()(); } /// Per-mailbox breakdown for a single sync cycle. @@ -185,7 +189,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 12; + int get schemaVersion => 13; @override MigrationStrategy get migration => MigrationStrategy( @@ -227,6 +231,10 @@ class AppDatabase extends _$AppDatabase { if (from < 12) { await m.createTable(syncLogMailboxes); } + if (from < 13) { + await m.addColumn(accounts, accounts.verbose); + await m.addColumn(syncLogs, syncLogs.protocolLog); + } }, ); } diff --git a/lib/data/imap/imap_client_factory.dart b/lib/data/imap/imap_client_factory.dart index d845f2f..12abe22 100644 --- a/lib/data/imap/imap_client_factory.dart +++ b/lib/data/imap/imap_client_factory.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; @@ -9,9 +11,18 @@ typedef ImapConnectFn = Future Function( String password, ); +/// Zone value key signalling that a [StringBuffer] for protocol logging is +/// active. When this key is non-null in the current zone, [connectImap] +/// enables IMAP trace logging so the output is captured by the zone's +/// print override. +const verboseLogKey = #verboseProtocolLog; + /// Opens an authenticated IMAP client for [account] using [username]. /// /// Throws [Exception] if the account is not configured for SSL/TLS. +/// +/// When the current [Zone] carries a [StringBuffer] under [verboseLogKey], +/// IMAP trace logging is enabled so each command/response is captured there. Future connectImap( Account account, String username, @@ -22,9 +33,10 @@ Future connectImap( 'Unencrypted IMAP connections are not allowed. Enable SSL/TLS.', ); } + final verboseBuffer = Zone.current[verboseLogKey] as StringBuffer?; final client = ImapClient( defaultResponseTimeout: const Duration(seconds: 20), - isLogEnabled: kDebugMode, + isLogEnabled: kDebugMode || verboseBuffer != null, ); await client.connectToServer(account.imapHost, account.imapPort); await client.login(username, password); diff --git a/lib/data/jmap/jmap_client.dart b/lib/data/jmap/jmap_client.dart index 5a62884..8c34e6f 100644 --- a/lib/data/jmap/jmap_client.dart +++ b/lib/data/jmap/jmap_client.dart @@ -1,8 +1,11 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; +import '../imap/imap_client_factory.dart' show verboseLogKey; + const _coreUsing = [ 'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', @@ -125,6 +128,14 @@ class JmapClient { ) .timeout(const Duration(seconds: 10)); + final log = Zone.current[verboseLogKey] as StringBuffer?; + if (log != null) { + log.writeln('JMAP → POST $_apiUrl'); + log.writeln(body); + log.writeln('JMAP ← ${resp.statusCode}'); + log.writeln(resp.body); + } + if (resp.statusCode != 200) { throw JmapException('API call failed (HTTP ${resp.statusCode})'); } diff --git a/lib/data/repositories/account_repository_impl.dart b/lib/data/repositories/account_repository_impl.dart index d971112..79a5e0e 100644 --- a/lib/data/repositories/account_repository_impl.dart +++ b/lib/data/repositories/account_repository_impl.dart @@ -41,6 +41,7 @@ class AccountRepositoryImpl implements AccountRepository { accountType: Value(account.type.name), jmapUrl: Value(account.jmapUrl), username: Value(account.username), + verbose: Value(account.verbose), ), ); await _storage.write(key: _passwordKey(account.id), value: password); @@ -62,6 +63,7 @@ class AccountRepositoryImpl implements AccountRepository { accountType: Value(account.type.name), jmapUrl: Value(account.jmapUrl), username: Value(account.username), + verbose: Value(account.verbose), ), ); if (password != null) { @@ -99,5 +101,6 @@ class AccountRepositoryImpl implements AccountRepository { smtpPort: row.smtpPort, smtpSsl: row.smtpSsl, jmapUrl: row.jmapUrl, + verbose: row.verbose, ); } diff --git a/lib/data/repositories/sync_log_repository_impl.dart b/lib/data/repositories/sync_log_repository_impl.dart index 4c7d706..ff2db7e 100644 --- a/lib/data/repositories/sync_log_repository_impl.dart +++ b/lib/data/repositories/sync_log_repository_impl.dart @@ -22,6 +22,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository { required DateTime startedAt, required DateTime finishedAt, List mailboxStats = const [], + String? protocolLog, }) async { await _db.transaction(() async { final logId = await _db.into(_db.syncLogs).insert( @@ -37,6 +38,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository { bytesTransferred: Value(bytesTransferred), startedAt: startedAt, finishedAt: finishedAt, + protocolLog: Value(protocolLog), ), ); for (final s in mailboxStats) { @@ -80,6 +82,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository { bytesTransferred: r.bytesTransferred, startedAt: r.startedAt, finishedAt: r.finishedAt, + protocolLog: r.protocolLog, mailboxStats: mailboxRows .map( (m) => MailboxSyncStats( diff --git a/lib/ui/screens/edit_account_screen.dart b/lib/ui/screens/edit_account_screen.dart index 0263d16..98e5dd3 100644 --- a/lib/ui/screens/edit_account_screen.dart +++ b/lib/ui/screens/edit_account_screen.dart @@ -31,6 +31,7 @@ class _EditAccountScreenState extends ConsumerState { final _smtpHostCtrl = TextEditingController(); final _smtpPortCtrl = TextEditingController(); var _smtpSsl = false; + var _verbose = false; final _jmapUrlCtrl = TextEditingController(); // -- "Try connection" state ------------------------------------------------ @@ -60,6 +61,7 @@ class _EditAccountScreenState extends ConsumerState { _smtpHostCtrl.text = account.smtpHost; _smtpPortCtrl.text = account.smtpPort.toString(); _smtpSsl = account.smtpSsl; + _verbose = account.verbose; _jmapUrlCtrl.text = account.jmapUrl ?? ''; setState(() => _loading = false); } @@ -96,6 +98,7 @@ class _EditAccountScreenState extends ConsumerState { smtpSsl: _smtpSsl, jmapUrl: _jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(), + verbose: _verbose, ); } @@ -160,6 +163,7 @@ class _EditAccountScreenState extends ConsumerState { smtpPort: updated.smtpPort, smtpSsl: updated.smtpSsl, jmapUrl: updated.jmapUrl, + verbose: updated.verbose, ); } } @@ -251,6 +255,16 @@ class _EditAccountScreenState extends ConsumerState { onChanged: (v) => setState(() => _smtpSsl = v), ), ], + const Divider(height: 32), + SwitchListTile( + title: const Text('Verbose protocol logging'), + subtitle: const Text( + 'Writes raw protocol traffic to the sync log. ' + 'Disable when not debugging.', + ), + value: _verbose, + onChanged: (v) => setState(() => _verbose = v), + ), if (_tryOk != null) Padding( padding: const EdgeInsets.only(top: 8), diff --git a/lib/ui/screens/sync_log_screen.dart b/lib/ui/screens/sync_log_screen.dart index be5b354..e6ef83d 100644 --- a/lib/ui/screens/sync_log_screen.dart +++ b/lib/ui/screens/sync_log_screen.dart @@ -115,6 +115,31 @@ class _SyncLogTile extends StatelessWidget { style: TextStyle(color: errorColor, fontSize: 12), ), ), + if (entry.protocolLog != null) ...[ + const Padding( + padding: EdgeInsets.only(top: 6, bottom: 2), + child: Text( + 'Protocol log', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + entry.protocolLog!, + style: const TextStyle( + fontSize: 10, + fontFamily: 'monospace', + color: Colors.greenAccent, + ), + ), + ), + ], ], ), ), diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index b0b8c3e..a557b1d 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -202,6 +202,7 @@ class _CapturingSyncLogRepository implements SyncLogRepository { required DateTime startedAt, required DateTime finishedAt, List mailboxStats = const [], + String? protocolLog, }) async { entries.add( SyncLogEntry(