krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

8465ef359f88ddef7cad68a0c2e361bb0bfe58ff

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-03T22:07:27Z

Add BIMI and MTA-STS checks to email security
 DomainDig.xcodeproj/project.pbxproj  |   8 +--
 DomainDig/ContentView.swift          |  33 ++++++++++-
 DomainDig/DomainViewModel.swift      |  18 +++++-
 DomainDig/EmailSecurityService.swift | 103 ++++++++++++++++++++++++++++++-----
 DomainDig/HistoryView.swift          |  33 ++++++++++-
 DomainDig/Models.swift               |  50 ++++++++++++++++-
 6 files changed, 222 insertions(+), 23 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 94acc4e..68d0d23 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -265,7 +265,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 6;
+				CURRENT_PROJECT_VERSION = 7;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.4.0;
+				MARKETING_VERSION = 1.5.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 6;
+				CURRENT_PROJECT_VERSION = 7;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.4.0;
+				MARKETING_VERSION = 1.5.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index c44f871..bb93789 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -381,6 +381,8 @@ struct ContentView: View {
                     emailSecurityRow("SPF", record: email.spf)
                     emailSecurityRow("DMARC", record: email.dmarc)
                     emailSecurityRow("DKIM", record: email.dkim)
+                    emailSecurityRow("MTA-STS", mtaSts: email.mtaSts)
+                    emailSecurityRow("BIMI", record: email.bimi)
                 }
             }
         }
@@ -394,7 +396,7 @@ struct ContentView: View {
                 Text(label)
                     .font(.system(.caption, design: .monospaced))
                     .fontWeight(.semibold)
-                    .frame(width: 52, alignment: .leading)
+                    .frame(width: 72, alignment: .leading)
                 Text(record.found ? "✓" : "✗")
                     .font(.system(.caption, design: .monospaced))
                     .foregroundStyle(record.found ? .green : .red)
@@ -411,6 +413,11 @@ struct ContentView: View {
                                 expandedEmailField = isExpanded ? nil : label
                             }
                         }
+                    if let selector = record.matchedSelector {
+                        Text("(selector: \(selector))")
+                            .font(.system(.caption2, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                    }
                 } else {
                     Text("No record found")
                         .font(.system(.caption2, design: .monospaced))
@@ -420,6 +427,30 @@ struct ContentView: View {
         }
     }
 
+    private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
+        VStack(alignment: .leading, spacing: 2) {
+            HStack(spacing: 8) {
+                Text(label)
+                    .font(.system(.caption, design: .monospaced))
+                    .fontWeight(.semibold)
+                    .frame(width: 72, alignment: .leading)
+                Text(mtaSts?.txtFound == true ? "✓" : "✗")
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
+                if let policyMode = mtaSts?.policyMode {
+                    Text(policyMode)
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.primary)
+                        .textSelection(.enabled)
+                } else {
+                    Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                }
+            }
+        }
+    }
+
     // MARK: - SSL Results
 
     private var sslResultsSection: some View {
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 0ce2d8c..16eaabd 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -115,6 +115,7 @@ final class DomainViewModel {
             reachabilityResults: reachabilityResults,
             ipGeolocation: ipGeolocation,
             emailSecurity: emailSecurity,
+            mtaSts: emailSecurity?.mtaSts,
             ptrRecord: ptrRecord,
             redirectChain: redirectChain,
             portScanResults: portScanResults,
@@ -482,7 +483,22 @@ final class DomainViewModel {
             lines.append("--------------")
             lines.append("  SPF:   \(email.spf.found ? "✓" : "✗")  \(email.spf.value ?? "No record found")")
             lines.append("  DMARC: \(email.dmarc.found ? "✓" : "✗")  \(email.dmarc.value ?? "No record found")")
-            lines.append("  DKIM:  \(email.dkim.found ? "✓" : "✗")  \(email.dkim.value ?? "No record found")")
+            let dkimValue = if let selector = email.dkim.matchedSelector,
+                               let value = email.dkim.value {
+                "\(value) (selector: \(selector))"
+            } else {
+                email.dkim.value ?? "No record found"
+            }
+            lines.append("  DKIM:  \(email.dkim.found ? "✓" : "✗")  \(dkimValue)")
+            let mtaDescription = if let mode = email.mtaSts?.policyMode {
+                "mode: \(mode)"
+            } else if email.mtaSts?.txtFound == true {
+                "Policy unavailable"
+            } else {
+                "No record found"
+            }
+            lines.append("  MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗")  \(mtaDescription)")
+            lines.append("  BIMI:  \(email.bimi.found ? "✓" : "✗")  \(email.bimi.value ?? "No record found")")
         }
 
         // SSL
diff --git a/DomainDig/EmailSecurityService.swift b/DomainDig/EmailSecurityService.swift
index 8922993..c6b05b0 100644
--- a/DomainDig/EmailSecurityService.swift
+++ b/DomainDig/EmailSecurityService.swift
@@ -1,6 +1,11 @@
 import Foundation
 
 struct EmailSecurityService {
+    private static let dkimSelectors = [
+        "default", "google", "mail", "selector1", "selector2", "k1",
+        "smtp", "dkim", "zoho", "mailchimp"
+    ]
+
     /// Analyze email security records. SPF is parsed from existing TXT records;
     /// DMARC and DKIM require additional DoH queries.
     static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult {
@@ -8,12 +13,19 @@ struct EmailSecurityService {
         let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") })
         let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value)
 
-        // DMARC and DKIM queries in parallel
+        // DMARC, DKIM, BIMI, and MTA-STS queries in parallel.
         async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)")
         async let dkimResult = queryDKIM(domain: domain)
+        async let bimiResult = queryMatchingTXT(
+            subdomain: "default._bimi.\(domain)",
+            prefix: "v=BIMI1"
+        )
+        async let mtaStsResult = queryMTASTS(domain: domain)
 
         let dmarcValue = await dmarcResult
         let dkimValue = await dkimResult
+        let bimiValue = await bimiResult
+        let mtaSts = await mtaStsResult
 
         let dmarc = EmailSecurityRecord(
             found: dmarcValue != nil,
@@ -21,10 +33,21 @@ struct EmailSecurityService {
         )
         let dkim = EmailSecurityRecord(
             found: dkimValue != nil,
-            value: dkimValue
+            value: dkimValue?.value,
+            matchedSelector: dkimValue?.selector
+        )
+        let bimi = EmailSecurityRecord(
+            found: bimiValue != nil,
+            value: bimiValue
         )
 
-        return EmailSecurityResult(spf: spf, dmarc: dmarc, dkim: dkim)
+        return EmailSecurityResult(
+            spf: spf,
+            dmarc: dmarc,
+            dkim: dkim,
+            bimi: bimi,
+            mtaSts: mtaSts
+        )
     }
 
     /// Query a TXT record for the given subdomain via DoH.
@@ -37,25 +60,75 @@ struct EmailSecurityService {
         }
     }
 
-    /// Try common DKIM selectors and return the first found.
-    private static func queryDKIM(domain: String) async -> String? {
-        let selectors = ["default", "google", "mail"]
-        return await withTaskGroup(of: (Int, String?).self, returning: String?.self) { group in
-            for (index, selector) in selectors.enumerated() {
+    private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? {
+        do {
+            let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
+            return records.first(where: { $0.value.hasPrefix(prefix) })?.value
+        } catch {
+            return nil
+        }
+    }
+
+    /// Try common DKIM selectors concurrently and return the first valid result.
+    private static func queryDKIM(domain: String) async -> (selector: String, value: String)? {
+        await withTaskGroup(of: (selector: String, value: String?).self) { group in
+            for selector in dkimSelectors {
                 group.addTask {
                     let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)")
-                    return (index, value)
+                    return (selector, value)
                 }
             }
 
-            var results: [(Int, String?)] = []
             for await result in group {
-                results.append(result)
+                if let value = result.value, !value.isEmpty {
+                    group.cancelAll()
+                    return (result.selector, value)
+                }
             }
-            // Return the first (by selector order) that has a value
-            return results
-                .sorted { $0.0 < $1.0 }
-                .first(where: { $0.1 != nil })?.1
+
+            return nil
+        }
+    }
+
+    private static func queryMTASTS(domain: String) async -> MTASTSResult? {
+        let txtValue = await queryMatchingTXT(subdomain: "_mta-sts.\(domain)", prefix: "v=STSv1")
+        guard txtValue != nil else {
+            return nil
         }
+
+        return MTASTSResult(
+            txtFound: true,
+            policyMode: await fetchMTASTSPolicyMode(domain: domain)
+        )
+    }
+
+    private static func fetchMTASTSPolicyMode(domain: String) async -> String? {
+        guard let url = URL(string: "https://mta-sts.\(domain)/.well-known/mta-sts.txt") else {
+            return nil
+        }
+
+        var request = URLRequest(url: url)
+        request.timeoutInterval = 5
+
+        do {
+            let (data, _) = try await URLSession.shared.data(for: request)
+            let policy = String(decoding: data, as: UTF8.self)
+
+            for line in policy.split(whereSeparator: \.isNewline) {
+                let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
+                guard trimmedLine.lowercased().hasPrefix("mode:") else {
+                    continue
+                }
+
+                let mode = trimmedLine.dropFirst("mode:".count)
+                    .trimmingCharacters(in: .whitespacesAndNewlines)
+                    .lowercased()
+                return ["enforce", "testing", "none"].contains(mode) ? mode : nil
+            }
+        } catch {
+            return nil
+        }
+
+        return nil
     }
 }
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 1963509..52b0070 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -254,6 +254,8 @@ struct HistoryDetailView: View {
                     historyEmailRow("SPF", record: email.spf)
                     historyEmailRow("DMARC", record: email.dmarc)
                     historyEmailRow("DKIM", record: email.dkim)
+                    historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts)
+                    historyEmailRow("BIMI", record: email.bimi)
                 }
             }
         }
@@ -267,7 +269,7 @@ struct HistoryDetailView: View {
                 Text(label)
                     .font(.system(.caption, design: .monospaced))
                     .fontWeight(.semibold)
-                    .frame(width: 52, alignment: .leading)
+                    .frame(width: 72, alignment: .leading)
                 Text(record.found ? "✓" : "✗")
                     .font(.system(.caption, design: .monospaced))
                     .foregroundStyle(record.found ? .green : .red)
@@ -284,6 +286,11 @@ struct HistoryDetailView: View {
                                 expandedEmailField = isExpanded ? nil : label
                             }
                         }
+                    if let selector = record.matchedSelector {
+                        Text("(selector: \(selector))")
+                            .font(.system(.caption2, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                    }
                 } else {
                     Text("No record found")
                         .font(.system(.caption2, design: .monospaced))
@@ -293,6 +300,30 @@ struct HistoryDetailView: View {
         }
     }
 
+    private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
+        VStack(alignment: .leading, spacing: 2) {
+            HStack(spacing: 8) {
+                Text(label)
+                    .font(.system(.caption, design: .monospaced))
+                    .fontWeight(.semibold)
+                    .frame(width: 72, alignment: .leading)
+                Text(mtaSts?.txtFound == true ? "✓" : "✗")
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
+                if let policyMode = mtaSts?.policyMode {
+                    Text(policyMode)
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.primary)
+                        .textSelection(.enabled)
+                } else {
+                    Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                }
+            }
+        }
+    }
+
     // MARK: - SSL
 
     private var sslSection: some View {
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 5fcc653..2635f41 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -159,11 +159,56 @@ struct EmailSecurityResult: Codable {
     let spf: EmailSecurityRecord
     let dmarc: EmailSecurityRecord
     let dkim: EmailSecurityRecord
+    let bimi: EmailSecurityRecord
+    let mtaSts: MTASTSResult?
+
+    init(
+        spf: EmailSecurityRecord,
+        dmarc: EmailSecurityRecord,
+        dkim: EmailSecurityRecord,
+        bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil),
+        mtaSts: MTASTSResult? = nil
+    ) {
+        self.spf = spf
+        self.dmarc = dmarc
+        self.dkim = dkim
+        self.bimi = bimi
+        self.mtaSts = mtaSts
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        spf = try container.decode(EmailSecurityRecord.self, forKey: .spf)
+        dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc)
+        dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim)
+        bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi)
+            ?? EmailSecurityRecord(found: false, value: nil)
+        mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts)
+    }
 }
 
 struct EmailSecurityRecord: Codable {
     let found: Bool
     let value: String?
+    let matchedSelector: String?
+
+    init(found: Bool, value: String?, matchedSelector: String? = nil) {
+        self.found = found
+        self.value = value
+        self.matchedSelector = matchedSelector
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        found = try container.decode(Bool.self, forKey: .found)
+        value = try container.decodeIfPresent(String.self, forKey: .value)
+        matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector)
+    }
+}
+
+struct MTASTSResult: Codable {
+    let txtFound: Bool
+    let policyMode: String?
 }
 
 // MARK: - Redirect Chain Models
@@ -197,6 +242,7 @@ struct HistoryEntry: Identifiable, Codable {
     let reachabilityResults: [PortReachability]
     let ipGeolocation: IPGeolocation?
     var emailSecurity: EmailSecurityResult?
+    var mtaSts: MTASTSResult?
     var ptrRecord: String?
     var redirectChain: [RedirectHop]
     var portScanResults: [PortScanResult]
@@ -205,7 +251,7 @@ struct HistoryEntry: Identifiable, Codable {
     init(domain: String, timestamp: Date, dnsSections: [DNSSection],
          sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
          reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
-         emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil,
+         emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
          redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
          hstsPreloaded: Bool? = nil) {
         self.domain = domain
@@ -216,6 +262,7 @@ struct HistoryEntry: Identifiable, Codable {
         self.reachabilityResults = reachabilityResults
         self.ipGeolocation = ipGeolocation
         self.emailSecurity = emailSecurity
+        self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
         self.ptrRecord = ptrRecord
         self.redirectChain = redirectChain
         self.portScanResults = portScanResults
@@ -233,6 +280,7 @@ struct HistoryEntry: Identifiable, Codable {
         reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults)
         ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
         emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
+        mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
         ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
         redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
         portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []