Commit 8465ef359f

8465ef359f88ddef7cad68a0c2e361bb0bfe58ff

parent: 1e13dac952

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-03 22:07 UTC

Add BIMI and MTA-STS checks to email security

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 @@
1import Foundation 1import Foundation
2 2
3struct EmailSecurityService { 3struct 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
164struct EmailSecurityRecord: Codable { 190struct 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
209struct 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) ?? []