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:
Thomas Güttler
2026-04-27 07:46:28 +02:00
co-authored by Claude Sonnet 4.6
parent e22418c6dd
commit 2b260edb52
4 changed files with 104 additions and 5 deletions
+8
View File
@@ -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;
}
}
}
-5
View File
@@ -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>());
});
});
}