diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c6d088..7edea4c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,11 @@ { "cSpell.words": [ - "Imap" + "autoconfig", + "cupertino", + "dnssec", + "EMAILDOMAIN", + "Imap", + "rrecord", + "starttls" ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 573e98d..b2bca25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ -## 1.0.1 +## 1.0.2 +* Fork DSN look-up from [basic_utils](https://github.com/Ephenodrom/Dart-Basic-Utils) into enough_mail_discovery to ensure full web-compatibility +* Improve documentation + +## 1.0.1 * Improve documentation * Add build dependency diff --git a/example/example/lib/main.dart b/example/example/lib/main.dart index 790d79d..f9213e0 100644 --- a/example/example/lib/main.dart +++ b/example/example/lib/main.dart @@ -99,8 +99,14 @@ class _MyHomePageState extends State { ), if (config != null) DiscoveredConfigViewer(config: config) - else if (_hasDiscovered) + else if (_hasDiscovered) ...[ + const SizedBox(height: 16), const Text('Unable to resolve settings...'), + const SizedBox(height: 8), + if (kIsWeb) + const Text('Note that enough_mail_discovery can find more ' + 'settings on non-web platforms') + ], ], ), ), @@ -110,10 +116,12 @@ class _MyHomePageState extends State { bool _checkEmailAddressValidity() { final emailAddress = _editingController.text; + final atIndex = emailAddress.indexOf('@'); + final lastDotIndex = emailAddress.lastIndexOf('.'); return emailAddress.length > 5 && - emailAddress.indexOf('@') > 1 && - emailAddress.contains('.') && - emailAddress.lastIndexOf('.') < emailAddress.length - 2; + atIndex > 1 && + lastDotIndex > atIndex + 1 && + lastDotIndex < emailAddress.length - 2; } Future _discover() async { @@ -125,6 +133,7 @@ class _MyHomePageState extends State { final emailAddress = _editingController.text; final result = await Discover.discover( emailAddress, + isLogEnabled: true, forceSslConnection: _forceSslConnection, isWeb: kIsWeb, ); diff --git a/lib/enough_mail_discovery.dart b/lib/enough_mail_discovery.dart index 91e7fab..735ce52 100644 --- a/lib/enough_mail_discovery.dart +++ b/lib/enough_mail_discovery.dart @@ -1,4 +1,4 @@ library enough_mail_discovery; -export 'src/discover.dart'; export 'src/client_config.dart'; +export 'src/discover.dart'; diff --git a/lib/src/discover.dart b/lib/src/discover.dart index ec68479..47565e7 100644 --- a/lib/src/discover.dart +++ b/lib/src/discover.dart @@ -1,6 +1,5 @@ -import 'discover_helper.dart'; - import 'client_config.dart'; +import 'discover_helper.dart'; /// Helps discovering email connection settings based on an email address. /// diff --git a/lib/src/discover_helper.dart b/lib/src/discover_helper.dart index 0c49434..8802eff 100644 --- a/lib/src/discover_helper.dart +++ b/lib/src/discover_helper.dart @@ -1,12 +1,13 @@ import 'dart:async'; -import 'package:basic_utils/basic_utils.dart' as basic; import 'package:collection/collection.dart' show IterableExtension; +import 'package:http/http.dart' as http; import 'package:universal_io/io.dart'; import 'package:xml/xml.dart' as xml; -import 'package:http/http.dart' as http; import 'client_config.dart'; +import 'dns/dns_utils.dart'; +import 'dns/rrecord_type.dart'; /// Low-level helper methods for mail scenarios class DiscoverHelper { @@ -70,8 +71,7 @@ class DiscoverHelper { /// Looks up domain referenced by the domain's DNS MX record static Future discoverMxDomain(String domain) async { - final mxRecords = - await basic.DnsUtils.lookupRecord(domain, basic.RRecordType.MX); + final mxRecords = await DnsUtils.lookupRecord(domain, RRecordType.MX); if (mxRecords == null || mxRecords.isEmpty) { //print('unable to read MX records for [$domain].'); return null; diff --git a/lib/src/dns/dns_utils.dart b/lib/src/dns/dns_utils.dart new file mode 100644 index 0000000..ef1f76a --- /dev/null +++ b/lib/src/dns/dns_utils.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'resolve_response.dart'; +import 'rrecord.dart'; +import 'rrecord_type.dart'; + +/// Supported DNS API providers +enum DnsApiProvider { + /// https://dns.google.com/resolve + google, + + ///https://cloudflare-dns.com/dns-query + cloudflare, +} + +/// Helper class for dns record lookups +/// +/// Migrated from https://github.com/Ephenodrom/Dart-Basic-Utils to +/// ensure compatibility with Flutter web. +class DnsUtils { + DnsUtils._(); + + /// Base url for each dns resolver + /// + static const _dnsApiProviderUrl = { + DnsApiProvider.google: 'https://dns.google.com/resolve', + DnsApiProvider.cloudflare: 'https://cloudflare-dns.com/dns-query', + }; + + /// Lookup for records of the given [type] and [name]. + /// It also supports [dnssec] + /// + static Future?> lookupRecord( + String name, + RRecordType type, { + bool dnssec = false, + DnsApiProvider provider = DnsApiProvider.google, + }) async { + final url = _dnsApiProviderUrl[provider]!; + final uri = Uri.parse( + '$url?name=$name&type=${_getTypeFromType(type)}&dnssec=$dnssec'); + + final headers = {'Accept': 'application/dns-json'}; + + final httpResponse = await http.get(uri, headers: headers); + final response = ResolveResponse.fromJson(jsonDecode(httpResponse.body)); + return response.answer; + } + + static String _getTypeFromType(RRecordType type) => + rRecordTypeToInt(type).toString(); + + /// + /// Converts the given number [type] to a [RRecordType] enum. + /// + static RRecordType intToRRecordType(int type) => + _intToRRecordType[type] ?? RRecordType.A; + + /// + /// Converts the given type to a decimal number + /// + static int rRecordTypeToInt(RRecordType type) => _rRecordTypeToInt[type] ?? 1; + + /// + /// Map from [RRecordType] enum to number + /// + static const _rRecordTypeToInt = { + RRecordType.A: 1, + RRecordType.AAAA: 28, + RRecordType.ANY: 255, + RRecordType.CAA: 257, + RRecordType.CDS: 59, + RRecordType.CERT: 37, + RRecordType.CNAME: 5, + RRecordType.DNAME: 39, + RRecordType.DNSKEY: 48, + RRecordType.DS: 43, + RRecordType.HINFO: 13, + RRecordType.IPSECKEY: 45, + RRecordType.MX: 15, + RRecordType.NAPTR: 35, + RRecordType.NS: 2, + RRecordType.NSEC: 47, + RRecordType.NSEC3PARAM: 51, + RRecordType.PTR: 12, + RRecordType.RP: 17, + RRecordType.RRSIG: 46, + RRecordType.SOA: 6, + RRecordType.SPF: 99, + RRecordType.SRV: 33, + RRecordType.SSHFP: 44, + RRecordType.TLSA: 52, + RRecordType.TXT: 16, + RRecordType.WKS: 11, + }; + + /// + /// Map from number to [RRecordType] enum + /// + static final _intToRRecordType = + _rRecordTypeToInt.map((k, v) => MapEntry(v, k)); + + /// Reverse lookup for the given [ip] to determine a hostname. + /// + /// This reverses the given [ip], adds ".in-addr.arpa" / ".ip6.arpa" and + /// tries to fetch a PTR record for the generated name. + /// + /// Will return null, if no IP address is given or no PTR is found. + /// + static Future?> reverseDns( + String ip, { + DnsApiProvider provider = DnsApiProvider.google, + }) async { + final reverse = getReverseAddr(ip); + if (reverse == null) { + return null; + } + + final url = _dnsApiProviderUrl[provider]; + final uri = Uri.parse( + '$url?name=$reverse&type=${_getTypeFromType(RRecordType.PTR)}'); + final headers = {'Accept': 'application/dns-json'}; + + final httpResponse = await http.get(uri, headers: headers); + final response = ResolveResponse.fromJson(jsonDecode(httpResponse.body)); + return response.answer; + } + + /// + /// Reverses the given [ip] address. Will return null if the given [ip] is not + /// an IP address. + /// + /// Example : + /// 172.217.22.14 => 14.22.217.172.in-addr.arpa + /// 2a00:1450:4001:81a::200e => e.0.0.2.a.1.8.1.0.0.4.0.5.4.1.0.0.a.2.ip6.arpa + /// + static String? getReverseAddr(String ip) { + if (ip.contains('.')) { + return '${ip.split('.').reversed.join('.')}.in-addr.arpa'; + } else if (ip.contains(':')) { + return '${ip.split(':').join().split('').reversed.join('.')}.ip6.arpa'; + } else { + return null; + } + } + + /// + /// Converts the record to the BIND representation. + /// + static String toBind(RRecord record) { + final sb = StringBuffer()..write(record.name); + if (sb.length < 8) { + sb.write('\t'); + } + if (sb.length < 16) { + sb.write('\t'); + } + sb + ..write('\t') + ..write(record.ttl) + ..write('\tIN\t') + ..write(intToRRecordType(record.rType) + .toString() + .substring('RRecordType.'.length)) + ..write('\t') + ..write('\"') + ..write(record.data) + ..write('\"'); + return sb.toString(); + } +} diff --git a/lib/src/dns/question.dart b/lib/src/dns/question.dart new file mode 100644 index 0000000..3605131 --- /dev/null +++ b/lib/src/dns/question.dart @@ -0,0 +1,25 @@ +// ignore_for_file: public_member_api_docs + +import 'package:json_annotation/json_annotation.dart'; + +part 'question.g.dart'; + +@JsonSerializable(includeIfNull: false) +class Question { + const Question({required this.name, required this.type}); + + /* + * Json to Question object + */ + factory Question.fromJson(Map json) => + _$QuestionFromJson(json); + + final String name; + + final int type; + + /* + * Question object to json + */ + Map toJson() => _$QuestionToJson(this); +} diff --git a/lib/src/dns/question.g.dart b/lib/src/dns/question.g.dart new file mode 100644 index 0000000..31abd18 --- /dev/null +++ b/lib/src/dns/question.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'question.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Question _$QuestionFromJson(Map json) => Question( + name: json['name'] as String, + type: json['type'] as int, + ); + +Map _$QuestionToJson(Question instance) => { + 'name': instance.name, + 'type': instance.type, + }; diff --git a/lib/src/dns/resolve_response.dart b/lib/src/dns/resolve_response.dart new file mode 100644 index 0000000..f76830a --- /dev/null +++ b/lib/src/dns/resolve_response.dart @@ -0,0 +1,51 @@ +// ignore_for_file: public_member_api_docs + +import 'package:json_annotation/json_annotation.dart'; + +import 'question.dart'; +import 'rrecord.dart'; + +part 'resolve_response.g.dart'; + +@JsonSerializable(includeIfNull: false) +class ResolveResponse { + const ResolveResponse({ + this.status, + this.tc, + this.rd, + this.ra, + this.ad, + this.cd, + this.question, + this.answer, + this.comment, + }); + + /// Create [ResolveResponse] from [json] + factory ResolveResponse.fromJson(Map json) => + _$ResolveResponseFromJson(json); + + @JsonKey(name: 'Status') + final int? status; + @JsonKey(name: 'TC') + final bool? tc; + @JsonKey(name: 'RD') + final bool? rd; + @JsonKey(name: 'RA') + final bool? ra; + @JsonKey(name: 'AD') + final bool? ad; + @JsonKey(name: 'CD') + final bool? cd; + @JsonKey(name: 'Question') + final List? question; + @JsonKey(name: 'Answer') + final List? answer; + @JsonKey(name: 'Comment') + final String? comment; + + /* + * ResolveResponse object to json + */ + Map toJson() => _$ResolveResponseToJson(this); +} diff --git a/lib/src/dns/resolve_response.g.dart b/lib/src/dns/resolve_response.g.dart new file mode 100644 index 0000000..d6a6db6 --- /dev/null +++ b/lib/src/dns/resolve_response.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'resolve_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ResolveResponse _$ResolveResponseFromJson(Map json) => + ResolveResponse( + status: json['Status'] as int?, + tc: json['TC'] as bool?, + rd: json['RD'] as bool?, + ra: json['RA'] as bool?, + ad: json['AD'] as bool?, + cd: json['CD'] as bool?, + question: (json['Question'] as List?) + ?.map((e) => Question.fromJson(e as Map)) + .toList(), + answer: (json['Answer'] as List?) + ?.map((e) => RRecord.fromJson(e as Map)) + .toList(), + comment: json['Comment'] as String?, + ); + +Map _$ResolveResponseToJson(ResolveResponse instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('Status', instance.status); + writeNotNull('TC', instance.tc); + writeNotNull('RD', instance.rd); + writeNotNull('RA', instance.ra); + writeNotNull('AD', instance.ad); + writeNotNull('CD', instance.cd); + writeNotNull('Question', instance.question); + writeNotNull('Answer', instance.answer); + writeNotNull('Comment', instance.comment); + return val; +} diff --git a/lib/src/dns/rrecord.dart b/lib/src/dns/rrecord.dart new file mode 100644 index 0000000..d71286a --- /dev/null +++ b/lib/src/dns/rrecord.dart @@ -0,0 +1,36 @@ +// ignore_for_file: public_member_api_docs + +import 'package:json_annotation/json_annotation.dart'; + +part 'rrecord.g.dart'; + +@JsonSerializable(includeIfNull: false) +class RRecord { + const RRecord({ + required this.name, + required this.rType, + required this.ttl, + required this.data, + }); + factory RRecord.fromJson(Map json) => + _$RRecordFromJson(json); + + /// The name of the record + final String name; + + /// The type of the record + @JsonKey(name: 'type') + final int rType; + + /// The time to live of the record + @JsonKey(name: 'TTL') + final int ttl; + + /// The data of the record + final String data; + + /* + * RRecord object to json + */ + Map toJson() => _$RRecordToJson(this); +} diff --git a/lib/src/dns/rrecord.g.dart b/lib/src/dns/rrecord.g.dart new file mode 100644 index 0000000..59d2e60 --- /dev/null +++ b/lib/src/dns/rrecord.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'rrecord.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RRecord _$RRecordFromJson(Map json) => RRecord( + name: json['name'] as String, + rType: json['type'] as int, + ttl: json['TTL'] as int, + data: json['data'] as String, + ); + +Map _$RRecordToJson(RRecord instance) => { + 'name': instance.name, + 'type': instance.rType, + 'TTL': instance.ttl, + 'data': instance.data, + }; diff --git a/lib/src/dns/rrecord_type.dart b/lib/src/dns/rrecord_type.dart new file mode 100644 index 0000000..8abfb16 --- /dev/null +++ b/lib/src/dns/rrecord_type.dart @@ -0,0 +1,31 @@ +// ignore_for_file: constant_identifier_names, public_member_api_docs + +enum RRecordType { + A, + AAAA, + ANY, + CAA, + CDS, + CERT, + DNAME, + DNSKEY, + DS, + HINFO, + IPSECKEY, + NSEC, + NSEC3PARAM, + NAPTR, + PTR, + RP, + RRSIG, + SOA, + SPF, + SRV, + SSHFP, + TLSA, + WKS, + TXT, + NS, + MX, + CNAME +} diff --git a/pubspec.yaml b/pubspec.yaml index eed9031..bb27170 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: enough_mail_discovery description: Discover email server and their settings based on an email. Discover IMAP, POP, SMTP and related settings. -version: 1.0.1 +version: 1.0.2 homepage: https://github.com/Enough-Software/enough_mail_discovery environment: @@ -8,7 +8,6 @@ environment: flutter: ">=1.17.0" dependencies: - basic_utils: ^5.1.2 collection: ^1.16.0 http: ^0.13.5 json_annotation: ^4.6.0 diff --git a/test/enough_mail_discovery_test.dart b/test/enough_mail_discovery_test.dart index 761352f..2874d8e 100644 --- a/test/enough_mail_discovery_test.dart +++ b/test/enough_mail_discovery_test.dart @@ -1,7 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:enough_mail_discovery/enough_mail_discovery.dart'; - void main() { test('adds one to input values', () { // TODO(RV): add tests