From 44e387bfb3ee83a49e0d1615655a442bbc5511d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Thu, 14 May 2026 10:29:07 +0200 Subject: [PATCH] fix: treat TLS config errors as permanent in sync loops (R5) (#45) --- lib/core/sync/account_sync_manager.dart | 3 ++ lib/data/imap/tls_error.dart | 45 ++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 72824bd..fc6d36b 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -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 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') || diff --git a/lib/data/imap/tls_error.dart b/lib/data/imap/tls_error.dart index ab12c7e..6e23172 100644 --- a/lib/data/imap/tls_error.dart +++ b/lib/data/imap/tls_error.dart @@ -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); }