Files
sharedinbox/lib/core/services/connection_test_service.dart
T
Thomas GüttlerandClaude Opus 4.7 da383d0957 feat: ManageSieve STARTTLS + clearer TLS-mismatch errors + broader connection test
The "Email filters" screen was failing with WRONG_VERSION_NUMBER because the
ManageSieve client was opening implicit-TLS sockets on port 4190, while RFC
5804 servers (Stalwart, Dovecot, Cyrus) listen plaintext on 4190 and expect
STARTTLS. ManageSieveClient.connect now opens plaintext, reads the capability
greeting, sends STARTTLS, hands the socket to SecureSocket.secure(), and
re-reads capabilities on the encrypted stream.

The same WRONG_VERSION_NUMBER error can hit IMAP/SMTP when the SSL toggle and
the chosen port disagree (e.g. SSL=on with SMTP port 587). New helper
lib/data/imap/tls_error.dart translates that BoringSSL error into a
TlsModeMismatchException naming the host/port and suggesting which port goes
with which TLS mode. connectImap, connectSmtp, and the ManageSieve TLS
upgrade all funnel through rethrowAsTlsHint so the same readable message
reaches the UI regardless of which protocol failed.

ConnectionTestService previously only verified IMAP/JMAP, so SMTP and
ManageSieve misconfig silently passed the "Try connection" button on the
edit-account screen and only surfaced when the user later tried to send
mail or open Email filters. After IMAP succeeds, the service now also
verifies SMTP (always — sending mail requires it) and ManageSieve (only
when manageSieveHost is explicitly set, since the section is collapsed by
default). Failures are prefixed with "SMTP:" or "ManageSieve:" so the
user can tell which leg of the connection is broken.

connectionTestServiceProvider now also watches smtpConnectProvider so the
E2E integration tests' plaintext SMTP override applies to the connection
check as well.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:31:55 +02:00

195 lines
6.0 KiB
Dart

import 'dart:convert';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/imap/managesieve_client.dart';
typedef ImapConnectForTestFn = Future<imap.ImapClient> Function(
Account,
String username,
String password,
);
typedef SmtpConnectForTestFn = Future<imap.SmtpClient> Function(
Account,
String username,
String password,
);
typedef ManageSieveConnectForTestFn = Future<ManageSieveClient> Function({
required String host,
required int port,
required bool useTls,
});
Future<ManageSieveClient> _defaultManageSieveConnect({
required String host,
required int port,
required bool useTls,
}) =>
ManageSieveClient.connect(host: host, port: port, useTls: useTls);
abstract class ConnectionTestService {
/// Verifies credentials and returns the effective username used.
/// Throws a descriptive [Exception] on failure.
Future<String> testConnection(Account account, String password);
}
class ConnectionTestServiceImpl implements ConnectionTestService {
ConnectionTestServiceImpl(
this._httpClient, {
ImapConnectForTestFn imapConnect = connectImap,
SmtpConnectForTestFn smtpConnect = connectSmtp,
ManageSieveConnectForTestFn manageSieveConnect = _defaultManageSieveConnect,
}) : _imapConnect = imapConnect,
_smtpConnect = smtpConnect,
_manageSieveConnect = manageSieveConnect;
final http.Client _httpClient;
final ImapConnectForTestFn _imapConnect;
final SmtpConnectForTestFn _smtpConnect;
final ManageSieveConnectForTestFn _manageSieveConnect;
@override
Future<String> testConnection(Account account, String password) async {
switch (account.type) {
case AccountType.imap:
final username = await _testImap(account, password);
// Also verify SMTP — without this, send-mail would fail later with
// no warning at account-edit time.
await _testSmtp(account, username, password);
// ManageSieve is opt-in: only test it if the user has explicitly
// filled in the host (the section is collapsed by default).
if (account.manageSieveHost.trim().isNotEmpty) {
await _testManageSieve(account, username, password);
}
return username;
case AccountType.jmap:
return _testJmap(account, password);
}
}
/// Returns the username candidates to try in order.
/// If [account.username] is non-empty it's the only candidate.
/// Otherwise: full email first, then the local part before '@'.
List<String> _usernamesFor(Account account) {
if (account.username.isNotEmpty) return [account.username];
final email = account.email;
final atIdx = email.indexOf('@');
if (atIdx <= 0) return [email];
final localPart = email.substring(0, atIdx);
return [email, localPart];
}
Future<String> _testImap(Account account, String password) async {
final candidates = _usernamesFor(account);
Object? lastError;
for (final username in candidates) {
try {
final client = await _imapConnect(account, username, password);
await client.logout();
return username;
} catch (e) {
lastError = e;
}
}
throw lastError!;
}
Future<void> _testSmtp(
Account account,
String username,
String password,
) async {
final imap.SmtpClient client;
try {
client = await _smtpConnect(account, username, password);
} catch (e) {
throw Exception('SMTP: $e');
}
try {
await client.quit();
} catch (_) {/* best-effort */}
}
Future<void> _testManageSieve(
Account account,
String username,
String password,
) async {
final host = account.manageSieveHost.trim().isNotEmpty
? account.manageSieveHost.trim()
: account.imapHost;
final ManageSieveClient client;
try {
client = await _manageSieveConnect(
host: host,
port: account.manageSievePort,
useTls: account.manageSieveSsl,
);
} catch (e) {
throw Exception('ManageSieve: $e');
}
try {
await client.authenticatePlain(username, password);
} catch (e) {
try {
await client.logout();
} catch (_) {/* best-effort */}
throw Exception('ManageSieve: $e');
}
try {
await client.logout();
} catch (_) {/* best-effort */}
}
Future<String> _testJmap(Account account, String password) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('No JMAP URL configured for this account');
}
final sessionUri = Uri.parse(jmapUrl);
final candidates = _usernamesFor(account);
Object? lastError;
for (final username in candidates) {
try {
final credentials = base64.encode(utf8.encode('$username:$password'));
final resp = await _httpClient.get(
sessionUri,
headers: {
'Authorization': 'Basic $credentials',
},
).timeout(const Duration(seconds: 10));
if (resp.statusCode == 401 || resp.statusCode == 403) {
lastError =
Exception('Authentication failed: wrong username or password');
continue;
}
if (resp.statusCode != 200) {
throw Exception('Connection failed (HTTP ${resp.statusCode})');
}
final Map<String, dynamic> session;
try {
session = jsonDecode(resp.body) as Map<String, dynamic>;
} on FormatException {
throw Exception(
'Not a JMAP server — unexpected response from $jmapUrl',
);
}
final caps = session['capabilities'];
if (caps is! Map || !caps.containsKey('urn:ietf:params:jmap:core')) {
throw Exception(
'Not a JMAP server — missing core capability at $jmapUrl',
);
}
return username;
} catch (e) {
lastError = e;
}
}
throw lastError!;
}
}