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(