TF-3976 Support DNS SRV resolvers without Cloudflare/Google dependency

This commit is contained in:
dab246
2025-10-28 11:29:37 +07:00
committed by Dat H. Pham
parent 17b48d947b
commit 37deead391
9 changed files with 393 additions and 96 deletions
@@ -1,23 +1,23 @@
import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
import 'package:tmail_ui_user/features/login/domain/model/recent_login_url.dart';
import 'package:tmail_ui_user/features/login/domain/model/recent_login_username.dart';
import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart';
class LoginDataSourceImpl implements LoginDataSource {
final DNSService _dnsService;
final DnsLookupManager _dnsLookupManager;
final ExceptionThrower _exceptionThrower;
LoginDataSourceImpl(
this._dnsService,
this._dnsLookupManager,
this._exceptionThrower
);
@override
Future<String> dnsLookupToGetJmapUrl(String emailAddress) {
return Future.sync(() async {
return await _dnsService.getJmapUrl(emailAddress);
return await _dnsLookupManager.lookupJmapUrl(emailAddress);
}).catchError(_exceptionThrower.throwException);
}
@@ -0,0 +1,97 @@
import 'dart:async';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/build_utils.dart';
import 'package:super_dns_client/super_dns_client.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_priority.dart';
/// Handles DNS SRV lookups for JMAP service discovery.
///
/// The manager attempts lookups in order of priority:
/// **System → Public UDP → Public DoH → Cloud (Google/Cloudflare)**.
class DnsLookupManager {
static const String _jmapServicePrefix = '_jmap._tcp';
static const Duration _defaultTimeout = Duration(seconds: 3);
/// Builds the JMAP SRV hostname from [emailAddress].
///
/// Example:
/// ```
/// input : user@example.com
/// output: _jmap._tcp.example.com
/// ```
String buildJmapHostName(String emailAddress) {
final parts = emailAddress.split('@');
if (parts.length != 2 || parts[1].isEmpty) {
throw ArgumentError('Invalid email address: $emailAddress');
}
return '$_jmapServicePrefix.${parts[1]}';
}
/// Creates the appropriate [DnsClient] for the given [priority].
DnsClient createClient(DnsLookupPriority priority) {
const debug = BuildUtils.isDebugMode;
switch (priority) {
case DnsLookupPriority.system:
return SystemUdpSrvClient(debugMode: debug);
case DnsLookupPriority.publicUdp:
return PublicUdpSrvClient(debugMode: debug);
case DnsLookupPriority.publicDoh:
return DnsOverHttpsBinaryClient(debugMode: debug);
case DnsLookupPriority.cloud:
return DnsOverHttps.google(debugMode: debug);
}
}
/// Attempts SRV resolution for the JMAP hostname derived from [emailAddress].
///
/// Each lookup attempt will timeout after [_defaultTimeout] seconds.
/// Returns the first successfully resolved hostname, or an empty string if all fail.
Future<String> lookupJmapUrl(String emailAddress) async {
final jmapHostName = buildJmapHostName(emailAddress);
log('$runtimeType::lookupJmapUrl → Resolving SRV for: $jmapHostName');
final priorities = List.of(DnsLookupPriority.values)
..sort((a, b) => a.priority.compareTo(b.priority));
for (final priority in priorities) {
final client = createClient(priority);
log('$runtimeType::lookupJmapUrl → 🔍 Trying ${priority.label} (timeout: ${_defaultTimeout.inSeconds}s)...');
try {
final records = client is DnsOverHttps
? await client.lookupSrvParallel(jmapHostName).timeout(
_defaultTimeout,
onTimeout: () {
throw TimeoutException(
'Lookup timed out after ${_defaultTimeout.inSeconds}s');
},
)
: await client.lookupSrv(jmapHostName).timeout(
_defaultTimeout,
onTimeout: () {
throw TimeoutException(
'Lookup timed out after ${_defaultTimeout.inSeconds}s');
},
);
if (records.isNotEmpty) {
final target = records.first.target;
log('$runtimeType::lookupJmapUrl → ✅ Success via ${priority.label}: $target');
return target;
}
log('$runtimeType::lookupJmapUrl → ⚠️ No records via ${priority.label}, continuing...');
} on TimeoutException catch (_) {
logError(
'$runtimeType::lookupJmapUrl → ⏱️ ${priority.label} lookup timed out');
} catch (error, stack) {
logError(
'$runtimeType::lookupJmapUrl → ❌ ${priority.label} lookup failed: $error, $stack');
}
}
log('$runtimeType::lookupJmapUrl → 🚨 All DNS lookups failed for $jmapHostName');
return '';
}
}
@@ -0,0 +1,27 @@
/// Represents the priority order and description of different DNS lookup modes.
///
/// Priority level increases with fallback order:
/// 1 → System default
/// 2 → Public resolvers (UDP/TCP or DOH)
/// 3 → Cloud resolvers (Google/Cloudflare)
enum DnsLookupPriority {
/// Uses the device's system-configured DNS (e.g., from ISP or OS settings).
system(1, 'System Default'),
/// Uses open DNS resolvers accessible via UDP/TCP (e.g., Quad9, OpenDNS).
publicUdp(2, 'Public DNS (UDP/TCP)'),
/// Uses DNS-over-HTTPS (DoH) resolvers for secure name resolution.
publicDoh(2, 'Public DNS (DoH)'),
/// Uses Google or Cloudflare DNS resolvers.
cloud(3, 'Cloud DNS (Google/Cloudflare)');
/// The lookup priority (lower means higher priority).
final int priority;
/// A human-readable description for UI or logging.
final String label;
const DnsLookupPriority(this.priority, this.label);
}
@@ -1,68 +0,0 @@
import 'package:core/utils/app_logger.dart';
import 'package:dns_client/dns_client.dart';
import 'package:tmail_ui_user/features/login/domain/exceptions/login_exception.dart';
class DNSService {
static const String _jmapServiceName = '_jmap._tcp';
Future<String> _dnsLookupToUrlFromSRVType({required String hostName}) async {
try {
final url = await _dnsLookupToUrlByGoogle(hostName: hostName);
if (url.isEmpty) {
return await _dnsLookupToUrlByCloudflare(hostName: hostName);
}
return url;
} catch (e) {
return await _dnsLookupToUrlByCloudflare(hostName: hostName);
}
}
Future<String> _dnsLookupToUrlByGoogle({required String hostName}) async {
final dns = DnsOverHttps.google();
final listData = await dns.lookupDataByRRType(hostName, RRType.SRVType);
if (listData.isEmpty) {
throw NotFoundDataResourceRecordException();
}
return _parsingUrlFromDataResourceRecord(listData.first);
}
Future<String> _dnsLookupToUrlByCloudflare({required String hostName}) async {
final dns = DnsOverHttps.cloudflare();
final listData = await dns.lookupDataByRRType(hostName, RRType.SRVType);
if (listData.isEmpty) {
throw NotFoundDataResourceRecordException();
}
return _parsingUrlFromDataResourceRecord(listData.first);
}
String _parsingUrlFromDataResourceRecord(String data) {
if (data.isEmpty) {
throw NotFoundDataResourceRecordException();
}
final listFieldData = data.split(' ');
if (listFieldData.isEmpty) {
throw NotFoundUrlException();
}
final url = _removeDotAtEndOfString(listFieldData.last);
log('DNSService::_parsingUrlFromDataResourceRecord:url: $url');
if (url.trim().isEmpty) {
throw NotFoundUrlException();
}
return url;
}
Future<String> getJmapUrl(String emailAddress) async {
final domainName = emailAddress.split('@')[1];
final jmapHostName = '$_jmapServiceName.$domainName';
log('DNSHandler::getJmapUrl:jmapHostName: $jmapHostName');
return await _dnsLookupToUrlFromSRVType(hostName: jmapHostName);
}
String _removeDotAtEndOfString(String value) {
if (value.lastIndexOf('.') == value.length - 1) {
return value.substring(0, value.length - 1);
}
return value;
}
}
@@ -7,7 +7,7 @@ import 'package:tmail_ui_user/features/login/data/datasource/login_datasource.da
import 'package:tmail_ui_user/features/login/data/datasource_impl/hive_login_datasource_impl.dart';
import 'package:tmail_ui_user/features/login/data/datasource_impl/login_datasource_impl.dart';
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
import 'package:tmail_ui_user/features/login/data/repository/login_repository_impl.dart';
import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart';
import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart';
@@ -74,7 +74,7 @@ class LoginBindings extends BaseBindings {
Get.find<CacheExceptionThrower>()
));
Get.lazyPut(() => LoginDataSourceImpl(
Get.find<DNSService>(),
Get.find<DnsLookupManager>(),
Get.find<RemoteExceptionThrower>(),
));
Get.lazyPut(() => SaasAuthenticationDataSourceImpl(
@@ -18,7 +18,7 @@ import 'package:tmail_ui_user/features/login/data/local/authentication_info_cach
import 'package:tmail_ui_user/features/login/data/local/oidc_configuration_cache_manager.dart';
import 'package:tmail_ui_user/features/login/data/local/token_oidc_cache_manager.dart';
import 'package:tmail_ui_user/features/login/data/network/authentication_client/authentication_client_base.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_service.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
import 'package:tmail_ui_user/features/login/data/network/interceptors/authorization_interceptors.dart';
import 'package:tmail_ui_user/features/login/data/network/oidc_http_client.dart';
import 'package:tmail_ui_user/features/login/data/utils/library_platform/app_auth_plugin/app_auth_plugin.dart';
@@ -144,6 +144,6 @@ class NetworkBindings extends Bindings {
}
void _bindingServices() {
Get.put(DNSService());
Get.put(DnsLookupManager());
}
}
+36 -13
View File
@@ -77,10 +77,10 @@ packages:
dependency: transitive
description:
name: args
sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.7.0"
async:
dependency: "direct main"
description:
@@ -463,15 +463,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
dns_client:
dependency: "direct main"
description:
path: "."
ref: twake-supported
resolved-ref: "0966c504c1a813ff40e22ea8896537c32d97b82d"
url: "https://github.com/linagora/dns_client.git"
source: git
version: "0.2.1"
dotted_border:
dependency: "direct main"
description:
@@ -1176,10 +1167,10 @@ packages:
dependency: transitive
description:
name: freezed_annotation
sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.4"
frontend_server_client:
dependency: transitive
description:
@@ -2166,6 +2157,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
super_dns:
dependency: transitive
description:
name: super_dns
sha256: "213d06aafacdcb342ba491faceb50cfca19e90df9cbd2216b39949b5080b283d"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
super_dns_client:
dependency: "direct main"
description:
name: super_dns_client
sha256: "78c2b9c38d96d95f85d495902fc3bc49354cd63db7c4ebd15faa55bff2e4d13f"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
super_ip:
dependency: transitive
description:
name: super_ip
sha256: "450f781cddd61b34793063e6f0508c190ca0664bb80a7df12a12b9ec4cd90d0e"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
super_raw:
dependency: transitive
description:
name: super_raw
sha256: "7f33a7b6d11b0a829c9a8eda27767ea63f8ef61e1bcc179055861a84f1ad5744"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
super_tag_editor:
dependency: "direct main"
description:
+2 -7
View File
@@ -90,13 +90,6 @@ dependencies:
url: https://github.com/linagora/flutter-date-range-picker.git
ref: main
# TODO: We will change it when the PR in upstream repository will be merged
# https://github.com/dietfriends/dns_client/pull/9
dns_client:
git:
url: https://github.com/linagora/dns_client.git
ref: twake-supported
linagora_design_flutter:
git:
url: https://github.com/linagora/linagora-design-flutter.git
@@ -112,6 +105,8 @@ dependencies:
### Dependencies from pub.dev ###
super_tag_editor: 1.1.0
super_dns_client: 0.3.1
external_app_launcher: 4.0.3
cupertino_icons: 1.0.6
@@ -0,0 +1,223 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:super_dns_client/super_dns_client.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_manager.dart';
import 'package:tmail_ui_user/features/login/data/network/dns_lookup/dns_lookup_priority.dart';
// Generate mock class for DnsClient
@GenerateMocks([DnsClient])
import 'dns_lookup_manager_test.mocks.dart';
void main() {
late MockDnsClient mockSystemClient;
late MockDnsClient mockPublicClient;
late MockDnsClient mockDohClient;
late MockDnsClient mockCloudClient;
setUp(() {
mockSystemClient = MockDnsClient();
mockPublicClient = MockDnsClient();
mockDohClient = MockDnsClient();
mockCloudClient = MockDnsClient();
});
group('DnsLookupManager.lookupJmapUrl', () {
test('✅ should return target when system resolver succeeds', () async {
// Arrange
when(mockSystemClient.lookupSrv(any)).thenAnswer((_) async => [
const SrvRecord(
name: '_jmap._tcp.example.com',
port: 443,
priority: 10,
weight: 5,
ttl: 3600,
target: 'mail.example.com',
)
]);
final manager = _TestableDnsLookupManager({
DnsLookupPriority.system: mockSystemClient,
DnsLookupPriority.publicUdp: mockPublicClient,
DnsLookupPriority.publicDoh: mockDohClient,
DnsLookupPriority.cloud: mockCloudClient,
});
// Act
final result = await manager.lookupJmapUrl('user@example.com');
// Assert
expect(result, equals('mail.example.com'));
verify(mockSystemClient.lookupSrv('_jmap._tcp.example.com')).called(1);
verifyNever(mockPublicClient.lookupSrv(any));
});
test('✅ should fall back when previous resolver fails', () async {
// Arrange
when(mockSystemClient.lookupSrv(any))
.thenThrow(Exception('System failed'));
when(mockPublicClient.lookupSrv(any)).thenAnswer((_) async => [
const SrvRecord(
name: '_jmap._tcp.example.com',
port: 443,
priority: 20,
weight: 5,
ttl: 3600,
target: 'mail-backup.example.com',
)
]);
final manager = _TestableDnsLookupManager({
DnsLookupPriority.system: mockSystemClient,
DnsLookupPriority.publicUdp: mockPublicClient,
DnsLookupPriority.publicDoh: mockDohClient,
DnsLookupPriority.cloud: mockCloudClient,
});
// Act
final result = await manager.lookupJmapUrl('user@example.com');
// Assert
expect(result, equals('mail-backup.example.com'));
verifyInOrder([
mockSystemClient.lookupSrv(any),
mockPublicClient.lookupSrv(any),
]);
});
test('✅ should skip to next resolver when previous returns empty list',
() async {
// Arrange
when(mockSystemClient.lookupSrv(any)).thenAnswer((_) async => []);
when(mockPublicClient.lookupSrv(any)).thenAnswer((_) async => [
const SrvRecord(
name: '_jmap._tcp.example.com',
port: 443,
priority: 15,
weight: 5,
ttl: 3600,
target: 'mail-fallback.example.com',
)
]);
final manager = _TestableDnsLookupManager({
DnsLookupPriority.system: mockSystemClient,
DnsLookupPriority.publicUdp: mockPublicClient,
DnsLookupPriority.publicDoh: mockDohClient,
DnsLookupPriority.cloud: mockCloudClient,
});
// Act
final result = await manager.lookupJmapUrl('user@example.com');
// Assert
expect(result, equals('mail-fallback.example.com'));
verifyInOrder([
mockSystemClient.lookupSrv(any),
mockPublicClient.lookupSrv(any),
]);
});
test('⚠️ should continue when some resolvers throw exceptions', () async {
// Arrange
when(mockSystemClient.lookupSrv(any))
.thenThrow(Exception('System crashed'));
when(mockPublicClient.lookupSrv(any))
.thenThrow(Exception('Network down'));
when(mockDohClient.lookupSrv(any)).thenAnswer((_) async => [
const SrvRecord(
name: '_jmap._tcp.example.com',
port: 443,
priority: 5,
weight: 5,
ttl: 3600,
target: 'mail-doh.example.com',
)
]);
final manager = _TestableDnsLookupManager({
DnsLookupPriority.system: mockSystemClient,
DnsLookupPriority.publicUdp: mockPublicClient,
DnsLookupPriority.publicDoh: mockDohClient,
DnsLookupPriority.cloud: mockCloudClient,
});
// Act
final result = await manager.lookupJmapUrl('user@example.com');
// Assert
expect(result, equals('mail-doh.example.com'));
verify(mockDohClient.lookupSrv(any)).called(1);
});
test('⏱️ should return empty string when all resolvers timeout', () async {
// Arrange: simulate all resolvers hanging
when(mockSystemClient.lookupSrv(any)).thenAnswer(
(_) => Future.delayed(const Duration(seconds: 10), () => []));
when(mockPublicClient.lookupSrv(any)).thenAnswer(
(_) => Future.delayed(const Duration(seconds: 10), () => []));
when(mockDohClient.lookupSrv(any)).thenAnswer(
(_) => Future.delayed(const Duration(seconds: 10), () => []));
when(mockCloudClient.lookupSrv(any)).thenAnswer(
(_) => Future.delayed(const Duration(seconds: 10), () => []));
final manager = _TestableDnsLookupManager({
DnsLookupPriority.system: mockSystemClient,
DnsLookupPriority.publicUdp: mockPublicClient,
DnsLookupPriority.publicDoh: mockDohClient,
DnsLookupPriority.cloud: mockCloudClient,
});
// Act
final result = await manager.lookupJmapUrl('user@example.com');
// Assert
expect(result, isEmpty);
});
test('❌ should throw ArgumentError when email is invalid', () async {
final manager = _TestableDnsLookupManager({});
expect(
() => manager.lookupJmapUrl('invalid-email'),
throwsA(isA<ArgumentError>()),
);
});
});
group('DnsLookupManager._buildJmapHostName', () {
test('✅ should build correct host name from email', () {
final manager = DnsLookupManager();
final result = manager.buildJmapHostNameForTest('user@domain.com');
expect(result, equals('_jmap._tcp.domain.com'));
});
test('❌ should throw ArgumentError for malformed email', () {
final manager = DnsLookupManager();
expect(
() => manager.buildJmapHostNameForTest('invalid'),
throwsA(isA<ArgumentError>()),
);
});
});
}
/// Test helper subclass that injects mock DNS clients.
class _TestableDnsLookupManager extends DnsLookupManager {
final Map<DnsLookupPriority, DnsClient> clients;
_TestableDnsLookupManager(this.clients);
@override
DnsClient createClient(DnsLookupPriority priority) {
final client = clients[priority];
if (client == null) {
throw StateError('No mock client provided for $priority');
}
return client;
}
}
/// Extends real DnsLookupManager only for testing private helpers.
extension DnsLookupManagerTestable on DnsLookupManager {
String buildJmapHostNameForTest(String email) => buildJmapHostName(email);
}