diff --git a/Apps/MagnumOpus/Services/AutoDiscovery.swift b/Apps/MagnumOpus/Services/AutoDiscovery.swift index 730b583..78c1937 100644 --- a/Apps/MagnumOpus/Services/AutoDiscovery.swift +++ b/Apps/MagnumOpus/Services/AutoDiscovery.swift @@ -6,22 +6,35 @@ struct DiscoveredServer: Sendable { var socketType: String } +struct DiscoveredConfig: Sendable { + var imap: DiscoveredServer? + var smtp: DiscoveredServer? +} + enum AutoDiscovery { - static func discoverIMAP(for email: String) async -> DiscoveredServer? { + static func discover(for email: String) async -> DiscoveredConfig? { guard let domain = email.split(separator: "@").last.map(String.init) else { return nil } - if let server = await queryISPDB(domain: domain) { - return server + if let config = await queryISPDB(domain: domain) { + return config } - if let server = await querySRV(domain: domain) { - return server - } + // Fall back to probing common hostnames + let imap = await probeIMAP(domain: domain) + let smtp = await probeSMTP(domain: domain) - return nil + guard imap != nil || smtp != nil else { return nil } + return DiscoveredConfig(imap: imap, smtp: smtp) } - private static func queryISPDB(domain: String) async -> DiscoveredServer? { + /// Convenience wrapper for IMAP-only discovery (backwards compatibility). + static func discoverIMAP(for email: String) async -> DiscoveredServer? { + await discover(for: email)?.imap + } + + // MARK: - ISPDB + + private static func queryISPDB(domain: String) async -> DiscoveredConfig? { 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, @@ -29,19 +42,23 @@ enum AutoDiscovery { let xml = String(data: data, encoding: .utf8) else { return nil } - return parseISPDBXML(xml) + let imap = parseISPDBXML(xml, serverType: "imap", tag: "incomingServer") + let smtp = parseISPDBXML(xml, serverType: "smtp", tag: "outgoingServer") + + guard imap != nil || smtp != nil else { return nil } + return DiscoveredConfig(imap: imap, smtp: smtp) } - private static func parseISPDBXML(_ xml: String) -> DiscoveredServer? { - guard let imapRange = xml.range(of: ""), - let endRange = xml.range(of: "", range: imapRange.upperBound.. DiscoveredServer? { + guard let startRange = xml.range(of: "<\(tag) type=\"\(serverType)\">"), + let endRange = xml.range(of: "", range: startRange.upperBound.. String? { - guard let start = section.range(of: "<\(tag)>"), - let end = section.range(of: "", range: start.upperBound.. String? { + guard let start = section.range(of: "<\(tagName)>"), + let end = section.range(of: "", range: start.upperBound.. DiscoveredServer? { + // MARK: - Probe fallbacks + + private static func probeIMAP(domain: String) async -> DiscoveredServer? { let candidates = [ "imap.\(domain)", "mail.\(domain)", @@ -68,6 +87,23 @@ enum AutoDiscovery { return nil } + private static func probeSMTP(domain: String) async -> DiscoveredServer? { + let candidates: [(String, Int, String)] = [ + ("smtp.\(domain)", 587, "STARTTLS"), + ("mail.\(domain)", 587, "STARTTLS"), + ("smtp.\(domain)", 465, "SSL"), + ("mail.\(domain)", 465, "SSL"), + ] + for (host, port, socketType) in candidates { + if await testConnection(host: host, port: port) { + return DiscoveredServer(hostname: host, port: port, socketType: socketType) + } + } + return nil + } + + // MARK: - Connection test + private static func testConnection(host: String, port: Int) async -> Bool { do { return try await withThrowingTaskGroup(of: Bool.self) { group in diff --git a/Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift b/Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift index b1371d5..ded0598 100644 --- a/Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift +++ b/Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift @@ -8,6 +8,9 @@ final class AccountSetupViewModel { var password: String = "" var imapHost: String = "" var imapPort: String = "993" + var smtpHost: String = "" + var smtpPort: String = "587" + var smtpSecurity: SMTPSecurity = .starttls var accountName: String = "" var isAutoDiscovering = false @@ -17,6 +20,7 @@ final class AccountSetupViewModel { var canSubmit: Bool { !email.isEmpty && !password.isEmpty && !imapHost.isEmpty && !imapPort.isEmpty + && !smtpHost.isEmpty && !smtpPort.isEmpty } func autoDiscover() async { @@ -24,10 +28,22 @@ final class AccountSetupViewModel { isAutoDiscovering = true autoDiscoveryFailed = false - if let server = await AutoDiscovery.discoverIMAP(for: email) { - imapHost = server.hostname - imapPort = String(server.port) + if let config = await AutoDiscovery.discover(for: email) { + if let imap = config.imap { + imapHost = imap.hostname + imapPort = String(imap.port) + } + if let smtp = config.smtp { + smtpHost = smtp.hostname + smtpPort = String(smtp.port) + smtpSecurity = smtp.socketType.uppercased() == "SSL" ? .ssl : .starttls + } isAutoDiscovering = false + // If neither was found, treat as failure + if config.imap == nil && config.smtp == nil { + autoDiscoveryFailed = true + isManualMode = true + } } else { isAutoDiscovering = false autoDiscoveryFailed = true @@ -36,7 +52,11 @@ final class AccountSetupViewModel { } func buildConfig() -> (AccountConfig, Credentials)? { - guard let port = Int(imapPort), canSubmit else { return nil } + guard let imapPortInt = Int(imapPort), + let smtpPortInt = Int(smtpPort), + canSubmit + else { return nil } + let id = email.replacingOccurrences(of: "@", with: "-at-") .replacingOccurrences(of: ".", with: "-") let config = AccountConfig( @@ -44,7 +64,10 @@ final class AccountSetupViewModel { name: accountName.isEmpty ? email : accountName, email: email, imapHost: imapHost, - imapPort: port + imapPort: imapPortInt, + smtpHost: smtpHost, + smtpPort: smtpPortInt, + smtpSecurity: smtpSecurity ) let credentials = Credentials(username: email, password: password) return (config, credentials) diff --git a/Apps/MagnumOpus/Views/AccountSetupView.swift b/Apps/MagnumOpus/Views/AccountSetupView.swift index 5131014..47a8c36 100644 --- a/Apps/MagnumOpus/Views/AccountSetupView.swift +++ b/Apps/MagnumOpus/Views/AccountSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Models struct AccountSetupView: View { @Bindable var viewModel: AccountSetupViewModel @@ -23,10 +24,19 @@ struct AccountSetupView: View { } if viewModel.isManualMode || viewModel.autoDiscoveryFailed { - Section("Server Settings") { + Section("IMAP") { TextField("IMAP Host", text: $viewModel.imapHost) TextField("IMAP Port", text: $viewModel.imapPort) } + + Section("SMTP") { + TextField("SMTP Host", text: $viewModel.smtpHost) + TextField("SMTP Port", text: $viewModel.smtpPort) + Picker("Security", selection: $viewModel.smtpSecurity) { + Text("STARTTLS").tag(SMTPSecurity.starttls) + Text("SSL/TLS").tag(SMTPSecurity.ssl) + } + } } if let error = viewModel.errorMessage {