feat: verbose protocol logging per account (schema v13)

When account.verbose is true, raw IMAP/JMAP protocol traffic is captured
via a Zone, redacted of credentials (LOGIN password, AUTHENTICATE tokens),
and stored in the sync log entry for display in the sync log screen.

- DB schema v13: adds verbose column to accounts, protocolLog to sync_logs
- IMAP: Zone print-capture feeds ImapClient isLogEnabled output
- JMAP: JmapClient.call() writes request/response bodies to zone buffer
- Sync log screen: shows a monospace "Protocol log" block when present
- Edit account screen: adds verbose toggle with warning subtitle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-21 16:34:55 +02:00
co-authored by Claude Sonnet 4.6
parent 81410c337a
commit 643bb47f87
11 changed files with 139 additions and 5 deletions
+6
View File
@@ -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,
});
}
@@ -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<MailboxSyncStats> 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<MailboxSyncStats> mailboxStats = const [],
String? protocolLog,
});
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId);
@@ -81,6 +84,7 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required DateTime startedAt,
required DateTime finishedAt,
List<MailboxSyncStats> mailboxStats = const [],
String? protocolLog,
}) async {}
@override
+50 -3
View File
@@ -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<MailboxSyncStats> 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]',
);
}
+9 -1
View File
@@ -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<Column> 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);
}
},
);
}
+13 -1
View File
@@ -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<ImapClient> 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<ImapClient> connectImap(
Account account,
String username,
@@ -22,9 +33,10 @@ Future<ImapClient> 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);
+11
View File
@@ -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})');
}
@@ -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,
);
}
@@ -22,6 +22,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required DateTime startedAt,
required DateTime finishedAt,
List<MailboxSyncStats> 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(
+14
View File
@@ -31,6 +31,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
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<EditAccountScreen> {
_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<EditAccountScreen> {
smtpSsl: _smtpSsl,
jmapUrl:
_jmapUrlCtrl.text.trim().isEmpty ? null : _jmapUrlCtrl.text.trim(),
verbose: _verbose,
);
}
@@ -160,6 +163,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
smtpPort: updated.smtpPort,
smtpSsl: updated.smtpSsl,
jmapUrl: updated.jmapUrl,
verbose: updated.verbose,
);
}
}
@@ -251,6 +255,16 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
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),
+25
View File
@@ -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,
),
),
),
],
],
),
),
+1
View File
@@ -202,6 +202,7 @@ class _CapturingSyncLogRepository implements SyncLogRepository {
required DateTime startedAt,
required DateTime finishedAt,
List<MailboxSyncStats> mailboxStats = const [],
String? protocolLog,
}) async {
entries.add(
SyncLogEntry(