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: "", 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)