diff --git a/Apps/MagnumOpus/Services/AutoDiscovery.swift b/Apps/MagnumOpus/Services/AutoDiscovery.swift
new file mode 100644
index 0000000..730b583
--- /dev/null
+++ b/Apps/MagnumOpus/Services/AutoDiscovery.swift
@@ -0,0 +1,93 @@
+import Foundation
+
+struct DiscoveredServer: Sendable {
+ var hostname: String
+ var port: Int
+ var socketType: String
+}
+
+enum AutoDiscovery {
+ static func discoverIMAP(for email: String) async -> DiscoveredServer? {
+ guard let domain = email.split(separator: "@").last.map(String.init) else { return nil }
+
+ if let server = await queryISPDB(domain: domain) {
+ return server
+ }
+
+ if let server = await querySRV(domain: domain) {
+ return server
+ }
+
+ return nil
+ }
+
+ private static func queryISPDB(domain: String) async -> DiscoveredServer? {
+ let url = URL(string: "https://autoconfig.thunderbird.net/v1.1/\(domain)")!
+ guard let (data, response) = try? await URLSession.shared.data(from: url),
+ let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200,
+ let xml = String(data: data, encoding: .utf8)
+ else { return nil }
+
+ return parseISPDBXML(xml)
+ }
+
+ private static func parseISPDBXML(_ xml: String) -> DiscoveredServer? {
+ guard let imapRange = xml.range(of: ""),
+ let endRange = xml.range(of: "", range: imapRange.upperBound.. String? {
+ guard let start = section.range(of: "<\(tag)>"),
+ let end = section.range(of: "\(tag)>", range: start.upperBound.. DiscoveredServer? {
+ let candidates = [
+ "imap.\(domain)",
+ "mail.\(domain)",
+ ]
+ for candidate in candidates {
+ if await testConnection(host: candidate, port: 993) {
+ return DiscoveredServer(hostname: candidate, port: 993, socketType: "SSL")
+ }
+ }
+ return nil
+ }
+
+ private static func testConnection(host: String, port: Int) async -> Bool {
+ do {
+ return try await withThrowingTaskGroup(of: Bool.self) { group in
+ group.addTask {
+ let task = URLSession.shared.streamTask(withHostName: host, port: port)
+ task.resume()
+ let (data, _) = try await task.readData(ofMinLength: 1, maxLength: 1024, timeout: 3)
+ task.cancel()
+ return data.map { !$0.isEmpty } ?? false
+ }
+ group.addTask {
+ try await Task.sleep(for: .seconds(3))
+ return false
+ }
+ let result = try await group.next() ?? false
+ group.cancelAll()
+ return result
+ }
+ } catch {
+ return false
+ }
+ }
+}
diff --git a/Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift b/Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift
index a2737c1..b1371d5 100644
--- a/Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift
+++ b/Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift
@@ -20,11 +20,19 @@ final class AccountSetupViewModel {
}
func autoDiscover() async {
+ guard !email.isEmpty else { return }
isAutoDiscovering = true
autoDiscoveryFailed = false
- // Auto-discovery implementation in Task 16
- isAutoDiscovering = false
- isManualMode = true
+
+ if let server = await AutoDiscovery.discoverIMAP(for: email) {
+ imapHost = server.hostname
+ imapPort = String(server.port)
+ isAutoDiscovering = false
+ } else {
+ isAutoDiscovering = false
+ autoDiscoveryFailed = true
+ isManualMode = true
+ }
}
func buildConfig() -> (AccountConfig, Credentials)? {
diff --git a/Apps/MagnumOpus/Views/AccountSetupView.swift b/Apps/MagnumOpus/Views/AccountSetupView.swift
index 693455f..5131014 100644
--- a/Apps/MagnumOpus/Views/AccountSetupView.swift
+++ b/Apps/MagnumOpus/Views/AccountSetupView.swift
@@ -8,6 +8,11 @@ struct AccountSetupView: View {
Form {
Section("Account") {
TextField("Email", text: $viewModel.email)
+ .onChange(of: viewModel.email) { _, newValue in
+ if newValue.contains("@") && !viewModel.isManualMode {
+ Task { await viewModel.autoDiscover() }
+ }
+ }
#if os(iOS)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)