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: "\(tag)>", range: startRange.upperBound.. String? {
- guard let start = section.range(of: "<\(tag)>"),
- let end = section.range(of: "\(tag)>", range: start.upperBound.. String? {
+ guard let start = section.range(of: "<\(tagName)>"),
+ let end = section.range(of: "\(tagName)>", 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 {