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:
co-authored by
Claude Sonnet 4.6
parent
81410c337a
commit
643bb47f87
@@ -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
|
||||
|
||||
@@ -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]',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -202,6 +202,7 @@ class _CapturingSyncLogRepository implements SyncLogRepository {
|
||||
required DateTime startedAt,
|
||||
required DateTime finishedAt,
|
||||
List<MailboxSyncStats> mailboxStats = const [],
|
||||
String? protocolLog,
|
||||
}) async {
|
||||
entries.add(
|
||||
SyncLogEntry(
|
||||
|
||||
Reference in New Issue
Block a user