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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user