summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-03 17:07:27 -0500
committerChristian Cleberg <[email protected]>2026-04-03 17:07:27 -0500
commit8465ef359f88ddef7cad68a0c2e361bb0bfe58ff (patch)
tree6b0c4dd9da3c50a617c4e5a6a99dd28ed50eaa80
parent1e13dac952d8f6cce7964aef27e98fa2e9da1eb7 (diff)
downloaddomain-dig-8465ef359f88ddef7cad68a0c2e361bb0bfe58ff.tar.gz
domain-dig-8465ef359f88ddef7cad68a0c2e361bb0bfe58ff.tar.bz2
domain-dig-8465ef359f88ddef7cad68a0c2e361bb0bfe58ff.zip
Add BIMI and MTA-STS checks to email securityv1.5.0
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDig/ContentView.swift33
-rw-r--r--DomainDig/DomainViewModel.swift18
-rw-r--r--DomainDig/EmailSecurityService.swift103
-rw-r--r--DomainDig/HistoryView.swift33
-rw-r--r--DomainDig/Models.swift50
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) ?? []