Commit 8465ef359f
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -265,7 +265,7 @@ | |||
| 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 267 | CODE_SIGN_STYLE = Automatic; | 267 | CODE_SIGN_STYLE = Automatic; |
| 268 | CURRENT_PROJECT_VERSION = 6; | 268 | CURRENT_PROJECT_VERSION = 7; |
| 269 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 269 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 270 | ENABLE_PREVIEWS = YES; | 270 | ENABLE_PREVIEWS = YES; |
| 271 | GENERATE_INFOPLIST_FILE = YES; | 271 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -282,7 +282,7 @@ | |||
| 282 | "$(inherited)", | 282 | "$(inherited)", |
| 283 | "@executable_path/Frameworks", | 283 | "@executable_path/Frameworks", |
| 284 | ); | 284 | ); |
| 285 | MARKETING_VERSION = 1.4.0; | 285 | MARKETING_VERSION = 1.5.0; |
| 286 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 286 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 287 | PRODUCT_NAME = "$(TARGET_NAME)"; | 287 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 288 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 288 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -301,7 +301,7 @@ | |||
| 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 303 | CODE_SIGN_STYLE = Automatic; | 303 | CODE_SIGN_STYLE = Automatic; |
| 304 | CURRENT_PROJECT_VERSION = 6; | 304 | CURRENT_PROJECT_VERSION = 7; |
| 305 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 305 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 306 | ENABLE_PREVIEWS = YES; | 306 | ENABLE_PREVIEWS = YES; |
| 307 | GENERATE_INFOPLIST_FILE = YES; | 307 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -318,7 +318,7 @@ | |||
| 318 | "$(inherited)", | 318 | "$(inherited)", |
| 319 | "@executable_path/Frameworks", | 319 | "@executable_path/Frameworks", |
| 320 | ); | 320 | ); |
| 321 | MARKETING_VERSION = 1.4.0; | 321 | MARKETING_VERSION = 1.5.0; |
| 322 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 322 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 323 | PRODUCT_NAME = "$(TARGET_NAME)"; | 323 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 324 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 324 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +32 −1
| @@ -381,6 +381,8 @@ struct ContentView: View { | |||
| 381 | emailSecurityRow("SPF", record: email.spf) | 381 | emailSecurityRow("SPF", record: email.spf) |
| 382 | emailSecurityRow("DMARC", record: email.dmarc) | 382 | emailSecurityRow("DMARC", record: email.dmarc) |
| 383 | emailSecurityRow("DKIM", record: email.dkim) | 383 | emailSecurityRow("DKIM", record: email.dkim) |
| 384 | emailSecurityRow("MTA-STS", mtaSts: email.mtaSts) | ||
| 385 | emailSecurityRow("BIMI", record: email.bimi) | ||
| 384 | } | 386 | } |
| 385 | } | 387 | } |
| 386 | } | 388 | } |
| @@ -394,7 +396,7 @@ struct ContentView: View { | |||
| 394 | Text(label) | 396 | Text(label) |
| 395 | .font(.system(.caption, design: .monospaced)) | 397 | .font(.system(.caption, design: .monospaced)) |
| 396 | .fontWeight(.semibold) | 398 | .fontWeight(.semibold) |
| 397 | .frame(width: 52, alignment: .leading) | 399 | .frame(width: 72, alignment: .leading) |
| 398 | Text(record.found ? "✓" : "✗") | 400 | Text(record.found ? "✓" : "✗") |
| 399 | .font(.system(.caption, design: .monospaced)) | 401 | .font(.system(.caption, design: .monospaced)) |
| 400 | .foregroundStyle(record.found ? .green : .red) | 402 | .foregroundStyle(record.found ? .green : .red) |
| @@ -411,6 +413,11 @@ struct ContentView: View { | |||
| 411 | expandedEmailField = isExpanded ? nil : label | 413 | expandedEmailField = isExpanded ? nil : label |
| 412 | } | 414 | } |
| 413 | } | 415 | } |
| 416 | if let selector = record.matchedSelector { | ||
| 417 | Text("(selector: \(selector))") | ||
| 418 | .font(.system(.caption2, design: .monospaced)) | ||
| 419 | .foregroundStyle(.secondary) | ||
| 420 | } | ||
| 414 | } else { | 421 | } else { |
| 415 | Text("No record found") | 422 | Text("No record found") |
| 416 | .font(.system(.caption2, design: .monospaced)) | 423 | .font(.system(.caption2, design: .monospaced)) |
| @@ -420,6 +427,30 @@ struct ContentView: View { | |||
| 420 | } | 427 | } |
| 421 | } | 428 | } |
| 422 | 429 | ||
| 430 | private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View { | ||
| 431 | VStack(alignment: .leading, spacing: 2) { | ||
| 432 | HStack(spacing: 8) { | ||
| 433 | Text(label) | ||
| 434 | .font(.system(.caption, design: .monospaced)) | ||
| 435 | .fontWeight(.semibold) | ||
| 436 | .frame(width: 72, alignment: .leading) | ||
| 437 | Text(mtaSts?.txtFound == true ? "✓" : "✗") | ||
| 438 | .font(.system(.caption, design: .monospaced)) | ||
| 439 | .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) | ||
| 440 | if let policyMode = mtaSts?.policyMode { | ||
| 441 | Text(policyMode) | ||
| 442 | .font(.system(.caption2, design: .monospaced)) | ||
| 443 | .foregroundStyle(.primary) | ||
| 444 | .textSelection(.enabled) | ||
| 445 | } else { | ||
| 446 | Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") | ||
| 447 | .font(.system(.caption2, design: .monospaced)) | ||
| 448 | .foregroundStyle(.secondary) | ||
| 449 | } | ||
| 450 | } | ||
| 451 | } | ||
| 452 | } | ||
| 453 | |||
| 423 | // MARK: - SSL Results | 454 | // MARK: - SSL Results |
| 424 | 455 | ||
| 425 | private var sslResultsSection: some View { | 456 | private var sslResultsSection: some View { |
DomainDig/DomainViewModel.swift +17 −1
| @@ -115,6 +115,7 @@ final class DomainViewModel { | |||
| 115 | reachabilityResults: reachabilityResults, | 115 | reachabilityResults: reachabilityResults, |
| 116 | ipGeolocation: ipGeolocation, | 116 | ipGeolocation: ipGeolocation, |
| 117 | emailSecurity: emailSecurity, | 117 | emailSecurity: emailSecurity, |
| 118 | mtaSts: emailSecurity?.mtaSts, | ||
| 118 | ptrRecord: ptrRecord, | 119 | ptrRecord: ptrRecord, |
| 119 | redirectChain: redirectChain, | 120 | redirectChain: redirectChain, |
| 120 | portScanResults: portScanResults, | 121 | portScanResults: portScanResults, |
| @@ -482,7 +483,22 @@ final class DomainViewModel { | |||
| 482 | lines.append("--------------") | 483 | lines.append("--------------") |
| 483 | lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")") | 484 | lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")") |
| 484 | lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")") | 485 | lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")") |
| 485 | lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(email.dkim.value ?? "No record found")") | 486 | let dkimValue = if let selector = email.dkim.matchedSelector, |
| 487 | let value = email.dkim.value { | ||
| 488 | "\(value) (selector: \(selector))" | ||
| 489 | } else { | ||
| 490 | email.dkim.value ?? "No record found" | ||
| 491 | } | ||
| 492 | lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)") | ||
| 493 | let mtaDescription = if let mode = email.mtaSts?.policyMode { | ||
| 494 | "mode: \(mode)" | ||
| 495 | } else if email.mtaSts?.txtFound == true { | ||
| 496 | "Policy unavailable" | ||
| 497 | } else { | ||
| 498 | "No record found" | ||
| 499 | } | ||
| 500 | lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)") | ||
| 501 | lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")") | ||
| 486 | } | 502 | } |
| 487 | 503 | ||
| 488 | // SSL | 504 | // SSL |
DomainDig/EmailSecurityService.swift +88 −15
| @@ -1,6 +1,11 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | struct EmailSecurityService { | 3 | struct EmailSecurityService { |
| 4 | private static let dkimSelectors = [ | ||
| 5 | "default", "google", "mail", "selector1", "selector2", "k1", | ||
| 6 | "smtp", "dkim", "zoho", "mailchimp" | ||
| 7 | ] | ||
| 8 | |||
| 4 | /// Analyze email security records. SPF is parsed from existing TXT records; | 9 | /// Analyze email security records. SPF is parsed from existing TXT records; |
| 5 | /// DMARC and DKIM require additional DoH queries. | 10 | /// DMARC and DKIM require additional DoH queries. |
| 6 | static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult { | 11 | static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult { |
| @@ -8,12 +13,19 @@ struct EmailSecurityService { | |||
| 8 | let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") }) | 13 | let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") }) |
| 9 | let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value) | 14 | let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value) |
| 10 | 15 | ||
| 11 | // DMARC and DKIM queries in parallel | 16 | // DMARC, DKIM, BIMI, and MTA-STS queries in parallel. |
| 12 | async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)") | 17 | async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)") |
| 13 | async let dkimResult = queryDKIM(domain: domain) | 18 | async let dkimResult = queryDKIM(domain: domain) |
| 19 | async let bimiResult = queryMatchingTXT( | ||
| 20 | subdomain: "default._bimi.\(domain)", | ||
| 21 | prefix: "v=BIMI1" | ||
| 22 | ) | ||
| 23 | async let mtaStsResult = queryMTASTS(domain: domain) | ||
| 14 | 24 | ||
| 15 | let dmarcValue = await dmarcResult | 25 | let dmarcValue = await dmarcResult |
| 16 | let dkimValue = await dkimResult | 26 | let dkimValue = await dkimResult |
| 27 | let bimiValue = await bimiResult | ||
| 28 | let mtaSts = await mtaStsResult | ||
| 17 | 29 | ||
| 18 | let dmarc = EmailSecurityRecord( | 30 | let dmarc = EmailSecurityRecord( |
| 19 | found: dmarcValue != nil, | 31 | found: dmarcValue != nil, |
| @@ -21,10 +33,21 @@ struct EmailSecurityService { | |||
| 21 | ) | 33 | ) |
| 22 | let dkim = EmailSecurityRecord( | 34 | let dkim = EmailSecurityRecord( |
| 23 | found: dkimValue != nil, | 35 | found: dkimValue != nil, |
| 24 | value: dkimValue | 36 | value: dkimValue?.value, |
| 37 | matchedSelector: dkimValue?.selector | ||
| 38 | ) | ||
| 39 | let bimi = EmailSecurityRecord( | ||
| 40 | found: bimiValue != nil, | ||
| 41 | value: bimiValue | ||
| 25 | ) | 42 | ) |
| 26 | 43 | ||
| 27 | return EmailSecurityResult(spf: spf, dmarc: dmarc, dkim: dkim) | 44 | return EmailSecurityResult( |
| 45 | spf: spf, | ||
| 46 | dmarc: dmarc, | ||
| 47 | dkim: dkim, | ||
| 48 | bimi: bimi, | ||
| 49 | mtaSts: mtaSts | ||
| 50 | ) | ||
| 28 | } | 51 | } |
| 29 | 52 | ||
| 30 | /// Query a TXT record for the given subdomain via DoH. | 53 | /// Query a TXT record for the given subdomain via DoH. |
| @@ -37,25 +60,75 @@ struct EmailSecurityService { | |||
| 37 | } | 60 | } |
| 38 | } | 61 | } |
| 39 | 62 | ||
| 40 | /// Try common DKIM selectors and return the first found. | 63 | private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? { |
| 41 | private static func queryDKIM(domain: String) async -> String? { | 64 | do { |
| 42 | let selectors = ["default", "google", "mail"] | 65 | let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT) |
| 43 | return await withTaskGroup(of: (Int, String?).self, returning: String?.self) { group in | 66 | return records.first(where: { $0.value.hasPrefix(prefix) })?.value |
| 44 | for (index, selector) in selectors.enumerated() { | 67 | } catch { |
| 68 | return nil | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | /// Try common DKIM selectors concurrently and return the first valid result. | ||
| 73 | private static func queryDKIM(domain: String) async -> (selector: String, value: String)? { | ||
| 74 | await withTaskGroup(of: (selector: String, value: String?).self) { group in | ||
| 75 | for selector in dkimSelectors { | ||
| 45 | group.addTask { | 76 | group.addTask { |
| 46 | let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)") | 77 | let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)") |
| 47 | return (index, value) | 78 | return (selector, value) |
| 48 | } | 79 | } |
| 49 | } | 80 | } |
| 50 | 81 | ||
| 51 | var results: [(Int, String?)] = [] | ||
| 52 | for await result in group { | 82 | for await result in group { |
| 53 | results.append(result) | 83 | if let value = result.value, !value.isEmpty { |
| 84 | group.cancelAll() | ||
| 85 | return (result.selector, value) | ||
| 86 | } | ||
| 54 | } | 87 | } |
| 55 | // Return the first (by selector order) that has a value | 88 | |
| 56 | return results | 89 | return nil |
| 57 | .sorted { $0.0 < $1.0 } | 90 | } |
| 58 | .first(where: { $0.1 != nil })?.1 | 91 | } |
| 92 | |||
| 93 | private static func queryMTASTS(domain: String) async -> MTASTSResult? { | ||
| 94 | let txtValue = await queryMatchingTXT(subdomain: "_mta-sts.\(domain)", prefix: "v=STSv1") | ||
| 95 | guard txtValue != nil else { | ||
| 96 | return nil | ||
| 59 | } | 97 | } |
| 98 | |||
| 99 | return MTASTSResult( | ||
| 100 | txtFound: true, | ||
| 101 | policyMode: await fetchMTASTSPolicyMode(domain: domain) | ||
| 102 | ) | ||
| 103 | } | ||
| 104 | |||
| 105 | private static func fetchMTASTSPolicyMode(domain: String) async -> String? { | ||
| 106 | guard let url = URL(string: "https://mta-sts.\(domain)/.well-known/mta-sts.txt") else { | ||
| 107 | return nil | ||
| 108 | } | ||
| 109 | |||
| 110 | var request = URLRequest(url: url) | ||
| 111 | request.timeoutInterval = 5 | ||
| 112 | |||
| 113 | do { | ||
| 114 | let (data, _) = try await URLSession.shared.data(for: request) | ||
| 115 | let policy = String(decoding: data, as: UTF8.self) | ||
| 116 | |||
| 117 | for line in policy.split(whereSeparator: \.isNewline) { | ||
| 118 | let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 119 | guard trimmedLine.lowercased().hasPrefix("mode:") else { | ||
| 120 | continue | ||
| 121 | } | ||
| 122 | |||
| 123 | let mode = trimmedLine.dropFirst("mode:".count) | ||
| 124 | .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 125 | .lowercased() | ||
| 126 | return ["enforce", "testing", "none"].contains(mode) ? mode : nil | ||
| 127 | } | ||
| 128 | } catch { | ||
| 129 | return nil | ||
| 130 | } | ||
| 131 | |||
| 132 | return nil | ||
| 60 | } | 133 | } |
| 61 | } | 134 | } |
DomainDig/HistoryView.swift +32 −1
| @@ -254,6 +254,8 @@ struct HistoryDetailView: View { | |||
| 254 | historyEmailRow("SPF", record: email.spf) | 254 | historyEmailRow("SPF", record: email.spf) |
| 255 | historyEmailRow("DMARC", record: email.dmarc) | 255 | historyEmailRow("DMARC", record: email.dmarc) |
| 256 | historyEmailRow("DKIM", record: email.dkim) | 256 | historyEmailRow("DKIM", record: email.dkim) |
| 257 | historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts) | ||
| 258 | historyEmailRow("BIMI", record: email.bimi) | ||
| 257 | } | 259 | } |
| 258 | } | 260 | } |
| 259 | } | 261 | } |
| @@ -267,7 +269,7 @@ struct HistoryDetailView: View { | |||
| 267 | Text(label) | 269 | Text(label) |
| 268 | .font(.system(.caption, design: .monospaced)) | 270 | .font(.system(.caption, design: .monospaced)) |
| 269 | .fontWeight(.semibold) | 271 | .fontWeight(.semibold) |
| 270 | .frame(width: 52, alignment: .leading) | 272 | .frame(width: 72, alignment: .leading) |
| 271 | Text(record.found ? "✓" : "✗") | 273 | Text(record.found ? "✓" : "✗") |
| 272 | .font(.system(.caption, design: .monospaced)) | 274 | .font(.system(.caption, design: .monospaced)) |
| 273 | .foregroundStyle(record.found ? .green : .red) | 275 | .foregroundStyle(record.found ? .green : .red) |
| @@ -284,6 +286,11 @@ struct HistoryDetailView: View { | |||
| 284 | expandedEmailField = isExpanded ? nil : label | 286 | expandedEmailField = isExpanded ? nil : label |
| 285 | } | 287 | } |
| 286 | } | 288 | } |
| 289 | if let selector = record.matchedSelector { | ||
| 290 | Text("(selector: \(selector))") | ||
| 291 | .font(.system(.caption2, design: .monospaced)) | ||
| 292 | .foregroundStyle(.secondary) | ||
| 293 | } | ||
| 287 | } else { | 294 | } else { |
| 288 | Text("No record found") | 295 | Text("No record found") |
| 289 | .font(.system(.caption2, design: .monospaced)) | 296 | .font(.system(.caption2, design: .monospaced)) |
| @@ -293,6 +300,30 @@ struct HistoryDetailView: View { | |||
| 293 | } | 300 | } |
| 294 | } | 301 | } |
| 295 | 302 | ||
| 303 | private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View { | ||
| 304 | VStack(alignment: .leading, spacing: 2) { | ||
| 305 | HStack(spacing: 8) { | ||
| 306 | Text(label) | ||
| 307 | .font(.system(.caption, design: .monospaced)) | ||
| 308 | .fontWeight(.semibold) | ||
| 309 | .frame(width: 72, alignment: .leading) | ||
| 310 | Text(mtaSts?.txtFound == true ? "✓" : "✗") | ||
| 311 | .font(.system(.caption, design: .monospaced)) | ||
| 312 | .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) | ||
| 313 | if let policyMode = mtaSts?.policyMode { | ||
| 314 | Text(policyMode) | ||
| 315 | .font(.system(.caption2, design: .monospaced)) | ||
| 316 | .foregroundStyle(.primary) | ||
| 317 | .textSelection(.enabled) | ||
| 318 | } else { | ||
| 319 | Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") | ||
| 320 | .font(.system(.caption2, design: .monospaced)) | ||
| 321 | .foregroundStyle(.secondary) | ||
| 322 | } | ||
| 323 | } | ||
| 324 | } | ||
| 325 | } | ||
| 326 | |||
| 296 | // MARK: - SSL | 327 | // MARK: - SSL |
| 297 | 328 | ||
| 298 | private var sslSection: some View { | 329 | private var sslSection: some View { |
DomainDig/Models.swift +49 −1
| @@ -159,11 +159,56 @@ struct EmailSecurityResult: Codable { | |||
| 159 | let spf: EmailSecurityRecord | 159 | let spf: EmailSecurityRecord |
| 160 | let dmarc: EmailSecurityRecord | 160 | let dmarc: EmailSecurityRecord |
| 161 | let dkim: EmailSecurityRecord | 161 | let dkim: EmailSecurityRecord |
| 162 | let bimi: EmailSecurityRecord | ||
| 163 | let mtaSts: MTASTSResult? | ||
| 164 | |||
| 165 | init( | ||
| 166 | spf: EmailSecurityRecord, | ||
| 167 | dmarc: EmailSecurityRecord, | ||
| 168 | dkim: EmailSecurityRecord, | ||
| 169 | bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil), | ||
| 170 | mtaSts: MTASTSResult? = nil | ||
| 171 | ) { | ||
| 172 | self.spf = spf | ||
| 173 | self.dmarc = dmarc | ||
| 174 | self.dkim = dkim | ||
| 175 | self.bimi = bimi | ||
| 176 | self.mtaSts = mtaSts | ||
| 177 | } | ||
| 178 | |||
| 179 | init(from decoder: Decoder) throws { | ||
| 180 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 181 | spf = try container.decode(EmailSecurityRecord.self, forKey: .spf) | ||
| 182 | dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc) | ||
| 183 | dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim) | ||
| 184 | bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi) | ||
| 185 | ?? EmailSecurityRecord(found: false, value: nil) | ||
| 186 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) | ||
| 187 | } | ||
| 162 | } | 188 | } |
| 163 | 189 | ||
| 164 | struct EmailSecurityRecord: Codable { | 190 | struct EmailSecurityRecord: Codable { |
| 165 | let found: Bool | 191 | let found: Bool |
| 166 | let value: String? | 192 | let value: String? |
| 193 | let matchedSelector: String? | ||
| 194 | |||
| 195 | init(found: Bool, value: String?, matchedSelector: String? = nil) { | ||
| 196 | self.found = found | ||
| 197 | self.value = value | ||
| 198 | self.matchedSelector = matchedSelector | ||
| 199 | } | ||
| 200 | |||
| 201 | init(from decoder: Decoder) throws { | ||
| 202 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 203 | found = try container.decode(Bool.self, forKey: .found) | ||
| 204 | value = try container.decodeIfPresent(String.self, forKey: .value) | ||
| 205 | matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector) | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | struct MTASTSResult: Codable { | ||
| 210 | let txtFound: Bool | ||
| 211 | let policyMode: String? | ||
| 167 | } | 212 | } |
| 168 | 213 | ||
| 169 | // MARK: - Redirect Chain Models | 214 | // MARK: - Redirect Chain Models |
| @@ -197,6 +242,7 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 197 | let reachabilityResults: [PortReachability] | 242 | let reachabilityResults: [PortReachability] |
| 198 | let ipGeolocation: IPGeolocation? | 243 | let ipGeolocation: IPGeolocation? |
| 199 | var emailSecurity: EmailSecurityResult? | 244 | var emailSecurity: EmailSecurityResult? |
| 245 | var mtaSts: MTASTSResult? | ||
| 200 | var ptrRecord: String? | 246 | var ptrRecord: String? |
| 201 | var redirectChain: [RedirectHop] | 247 | var redirectChain: [RedirectHop] |
| 202 | var portScanResults: [PortScanResult] | 248 | var portScanResults: [PortScanResult] |
| @@ -205,7 +251,7 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 205 | init(domain: String, timestamp: Date, dnsSections: [DNSSection], | 251 | init(domain: String, timestamp: Date, dnsSections: [DNSSection], |
| 206 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], | 252 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], |
| 207 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, | 253 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 208 | emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil, | 254 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, |
| 209 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], | 255 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], |
| 210 | hstsPreloaded: Bool? = nil) { | 256 | hstsPreloaded: Bool? = nil) { |
| 211 | self.domain = domain | 257 | self.domain = domain |
| @@ -216,6 +262,7 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 216 | self.reachabilityResults = reachabilityResults | 262 | self.reachabilityResults = reachabilityResults |
| 217 | self.ipGeolocation = ipGeolocation | 263 | self.ipGeolocation = ipGeolocation |
| 218 | self.emailSecurity = emailSecurity | 264 | self.emailSecurity = emailSecurity |
| 265 | self.mtaSts = mtaSts ?? emailSecurity?.mtaSts | ||
| 219 | self.ptrRecord = ptrRecord | 266 | self.ptrRecord = ptrRecord |
| 220 | self.redirectChain = redirectChain | 267 | self.redirectChain = redirectChain |
| 221 | self.portScanResults = portScanResults | 268 | self.portScanResults = portScanResults |
| @@ -233,6 +280,7 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 233 | reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults) | 280 | reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults) |
| 234 | ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) | 281 | ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) |
| 235 | emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) | 282 | emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) |
| 283 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts | ||
| 236 | ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) | 284 | ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) |
| 237 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] | 285 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] |
| 238 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] | 286 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] |