extend AutoDiscovery for SMTP, add SMTP fields to account setup UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 05:32:17 +01:00
parent cf2b463fd3
commit 7d847693d7
3 changed files with 92 additions and 23 deletions

View File

@@ -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: "<incomingServer type=\"imap\">"),
let endRange = xml.range(of: "</incomingServer>", range: imapRange.upperBound..<xml.endIndex)
private static func parseISPDBXML(_ xml: String, serverType: String, tag: String) -> DiscoveredServer? {
guard let startRange = xml.range(of: "<\(tag) type=\"\(serverType)\">"),
let endRange = xml.range(of: "</\(tag)>", range: startRange.upperBound..<xml.endIndex)
else { return nil }
let section = String(xml[imapRange.upperBound..<endRange.lowerBound])
let section = String(xml[startRange.upperBound..<endRange.lowerBound])
func extractTag(_ tag: String) -> String? {
guard let start = section.range(of: "<\(tag)>"),
let end = section.range(of: "</\(tag)>", range: start.upperBound..<section.endIndex)
func extractTag(_ tagName: String) -> String? {
guard let start = section.range(of: "<\(tagName)>"),
let end = section.range(of: "</\(tagName)>", range: start.upperBound..<section.endIndex)
else { return nil }
return String(section[start.upperBound..<end.lowerBound])
}
@@ -55,7 +72,9 @@ enum AutoDiscovery {
return DiscoveredServer(hostname: hostname, port: port, socketType: socketType)
}
private static func querySRV(domain: String) async -> 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

View File

@@ -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)

View File

@@ -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 {