feat: MX record fallback in account auto-discovery
When JMAP well-known and autoconfig XML both fail, query DNS-over-HTTPS (dns.google) for MX records and use the highest-priority MX host as IMAP (993/SSL) and SMTP (587/STARTTLS) server. No new dependencies needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e22418c6dd
commit
2b260edb52
@@ -6,6 +6,14 @@ Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks
|
||||
|
||||
## MX record fallback in account auto-discovery
|
||||
|
||||
When JMAP well-known and autoconfig XML both fail, `AccountDiscoveryServiceImpl` now
|
||||
queries `https://dns.google/resolve?name={domain}&type=MX` (DNS-over-HTTPS, no new
|
||||
dependency). The highest-priority MX hostname is used as both IMAP host (port 993, SSL)
|
||||
and SMTP host (port 587, STARTTLS). Three unit tests cover: basic MX hit, priority
|
||||
sorting, and NXDOMAIN/error fallback to `UnknownDiscovery`.
|
||||
|
||||
## Email filters accessible from inside an account
|
||||
|
||||
Added "Email filters" entry to `FolderDrawer` (below "All accounts", above the folder
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:sharedinbox/core/models/discovery_result.dart';
|
||||
@@ -23,6 +25,9 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService {
|
||||
final imap = await _tryImapAutoconfig(domain);
|
||||
if (imap != null) return imap;
|
||||
|
||||
final mx = await _tryMxFallback(domain);
|
||||
if (mx != null) return mx;
|
||||
|
||||
return UnknownDiscovery();
|
||||
}
|
||||
|
||||
@@ -101,4 +106,50 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService {
|
||||
|
||||
String? _tag(String block, String tag) =>
|
||||
RegExp('<$tag>([^<]+)</$tag>').firstMatch(block)?.group(1)?.trim();
|
||||
|
||||
/// Queries DNS-over-HTTPS for MX records and returns an [ImapSmtpDiscovery]
|
||||
/// using the highest-priority MX host with standard ports (IMAP 993/SSL,
|
||||
/// SMTP 587/STARTTLS). Used as a last-resort fallback when neither the JMAP
|
||||
/// well-known endpoint nor the autoconfig XML was found.
|
||||
Future<ImapSmtpDiscovery?> _tryMxFallback(String domain) async {
|
||||
try {
|
||||
final url = Uri.https(
|
||||
'dns.google',
|
||||
'/resolve',
|
||||
{'name': domain, 'type': 'MX'},
|
||||
);
|
||||
final resp = await _client.get(url).timeout(const Duration(seconds: 5));
|
||||
if (resp.statusCode != 200) return null;
|
||||
|
||||
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
if (body['Status'] != 0) return null;
|
||||
|
||||
final answers = (body['Answer'] as List?)?.cast<Map<String, dynamic>>();
|
||||
if (answers == null || answers.isEmpty) return null;
|
||||
|
||||
final entries = <MapEntry<int, String>>[];
|
||||
for (final a in answers.where((a) => a['type'] == 15)) {
|
||||
final parts = (a['data'] as String).trim().split(RegExp(r'\s+'));
|
||||
if (parts.length < 2) continue;
|
||||
final priority = int.tryParse(parts[0]);
|
||||
if (priority == null) continue;
|
||||
final host = parts[1].replaceAll(RegExp(r'\.$'), '');
|
||||
if (host.isNotEmpty) entries.add(MapEntry(priority, host));
|
||||
}
|
||||
if (entries.isEmpty) return null;
|
||||
entries.sort((a, b) => a.key.compareTo(b.key));
|
||||
final host = entries.first.value;
|
||||
|
||||
return ImapSmtpDiscovery(
|
||||
imapHost: host,
|
||||
imapPort: 993,
|
||||
imapSsl: true,
|
||||
smtpHost: host,
|
||||
smtpPort: 587,
|
||||
smtpSsl: false,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,6 @@ Then commit.
|
||||
|
||||
## Tasks
|
||||
|
||||
When adding a new account, and no well-known file was found, not exact hint in DNS, then
|
||||
SMTP/IMAP/JMAP should use the mx record as fallback.
|
||||
|
||||
---
|
||||
|
||||
How can I edit Sieve Scripts? Afaik this feature was added.
|
||||
|
||||
---
|
||||
|
||||
@@ -119,5 +119,50 @@ void main() {
|
||||
final result = await svc.discover('user@example.com');
|
||||
expect(result, isA<UnknownDiscovery>());
|
||||
});
|
||||
|
||||
test('returns ImapSmtpDiscovery from MX record when autoconfig not found',
|
||||
() async {
|
||||
final svc = _service({
|
||||
'https://dns.google/resolve?name=example.com&type=MX': http.Response(
|
||||
'{"Status":0,"Answer":[{"type":15,"data":"10 mail.example.com."}]}',
|
||||
200,
|
||||
),
|
||||
});
|
||||
final result = await svc.discover('user@example.com');
|
||||
expect(result, isA<ImapSmtpDiscovery>());
|
||||
final imap = result as ImapSmtpDiscovery;
|
||||
expect(imap.imapHost, 'mail.example.com');
|
||||
expect(imap.imapPort, 993);
|
||||
expect(imap.imapSsl, isTrue);
|
||||
expect(imap.smtpHost, 'mail.example.com');
|
||||
expect(imap.smtpPort, 587);
|
||||
expect(imap.smtpSsl, isFalse);
|
||||
});
|
||||
|
||||
test('MX fallback picks lowest priority record', () async {
|
||||
final svc = _service({
|
||||
'https://dns.google/resolve?name=example.com&type=MX': http.Response(
|
||||
'{"Status":0,"Answer":['
|
||||
'{"type":15,"data":"20 backup.example.com."},'
|
||||
'{"type":15,"data":"10 mail.example.com."}'
|
||||
']}',
|
||||
200,
|
||||
),
|
||||
});
|
||||
final result = await svc.discover('user@example.com');
|
||||
expect(result, isA<ImapSmtpDiscovery>());
|
||||
expect((result as ImapSmtpDiscovery).imapHost, 'mail.example.com');
|
||||
});
|
||||
|
||||
test('returns UnknownDiscovery when MX lookup also fails', () async {
|
||||
final svc = _service({
|
||||
'https://dns.google/resolve?name=example.com&type=MX': http.Response(
|
||||
'{"Status":3}',
|
||||
200,
|
||||
),
|
||||
});
|
||||
final result = await svc.discover('user@example.com');
|
||||
expect(result, isA<UnknownDiscovery>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user