krz/domain-dig

an ios app for DNS & SSL analysis

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

376e7b55b4a0c4f54faa0c2828dea4fd17dff288

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-18T16:59:38Z

realigning commits
 DomainDig.xcodeproj/project.pbxproj  |  23 +-
 DomainDig/ContentView.swift          | 402 ++++++++++++++++++++++++++--
 DomainDig/DNSLookupService.swift     |   9 +-
 DomainDig/DomainViewModel.swift      | 428 +++++++++++++++++++++++++++++-
 DomainDig/EmailSecurityService.swift |  61 +++++
 DomainDig/HTTPHeadersService.swift   |  22 ++
 DomainDig/HistoryView.swift          | 496 +++++++++++++++++++++++++++++++++++
 DomainDig/IPGeolocationService.swift |  17 ++
 DomainDig/Info.plist                 |  10 +
 DomainDig/Models.swift               | 136 +++++++++-
 DomainDig/PortScanService.swift      |  93 +++++++
 DomainDig/ReachabilityService.swift  |  67 +++++
 DomainDig/RedirectChainService.swift |  82 ++++++
 DomainDig/ReverseDNSService.swift    |  48 ++++
 DomainDig/SavedDomainsView.swift     |  42 +++
 claude.md                            | 134 ++++++++--
 16 files changed, 2006 insertions(+), 64 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 706ba0f..f37b290 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -10,9 +10,22 @@
 		8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
 /* End PBXFileReference section */
 
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+		8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = {
+			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+			membershipExceptions = (
+				Info.plist,
+			);
+			target = 8B7800682F6090E300933221 /* DomainDig */;
+		};
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
 /* Begin PBXFileSystemSynchronizedRootGroup section */
 		8B78006B2F6090E300933221 /* DomainDig */ = {
 			isa = PBXFileSystemSynchronizedRootGroup;
+			exceptions = (
+				8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */,
+			);
 			path = DomainDig;
 			sourceTree = "<group>";
 		};
@@ -252,10 +265,11 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = 3;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_FILE = DomainDig/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
 				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -268,7 +282,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.2;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -287,10 +301,11 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = 3;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_FILE = DomainDig/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
 				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -303,7 +318,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = 1.2;
 				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 b6588b4..940a25b 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -1,4 +1,5 @@
 import SwiftUI
+import MapKit
 
 struct ContentView: View {
     @State private var viewModel = DomainViewModel()
@@ -10,21 +11,15 @@ struct ContentView: View {
                 VStack(spacing: 0) {
                     inputSection
                     if viewModel.hasRun {
-                        if viewModel.resultsLoaded {
-                            HStack {
-                                Spacer()
-                                Button {
-                                    shareResults()
-                                } label: {
-                                    Image(systemName: "square.and.arrow.up")
-                                        .font(.system(.body))
-                                        .foregroundStyle(.secondary)
-                                }
-                            }
-                            .padding(.top, 8)
-                        }
+                        actionButtons
+                        reachabilitySection
+                        redirectChainSection
                         dnsResultsSection
+                        emailSecuritySection
                         sslResultsSection
+                        httpHeadersSection
+                        ipGeolocationSection
+                        portScanSection
                     } else if !viewModel.recentSearches.isEmpty {
                         recentSearchesSection
                     }
@@ -36,6 +31,30 @@ struct ContentView: View {
             .navigationTitle("DomainDig")
             .toolbarColorScheme(.dark, for: .navigationBar)
             .preferredColorScheme(.dark)
+            .toolbar {
+                ToolbarItemGroup(placement: .topBarTrailing) {
+                    if viewModel.hasRun {
+                        Button {
+                            viewModel.reset()
+                        } label: {
+                            Image(systemName: "xmark.circle")
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+                    NavigationLink {
+                        SavedDomainsView(viewModel: viewModel)
+                    } label: {
+                        Image(systemName: "bookmark")
+                            .foregroundStyle(.secondary)
+                    }
+                    NavigationLink {
+                        HistoryView(viewModel: viewModel)
+                    } label: {
+                        Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
+                            .foregroundStyle(.secondary)
+                    }
+                }
+            }
         }
         .onAppear {
             domainFieldFocused = true
@@ -72,6 +91,31 @@ struct ContentView: View {
         .padding(.vertical, 16)
     }
 
+    // MARK: - Action Buttons (Share + Bookmark)
+
+    private var actionButtons: some View {
+        HStack {
+            Spacer()
+            if viewModel.resultsLoaded {
+                Button {
+                    viewModel.toggleSavedDomain()
+                } label: {
+                    Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
+                        .font(.system(.body))
+                        .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
+                }
+                Button {
+                    shareResults()
+                } label: {
+                    Image(systemName: "square.and.arrow.up")
+                        .font(.system(.body))
+                        .foregroundStyle(.secondary)
+                }
+            }
+        }
+        .padding(.top, 8)
+    }
+
     // MARK: - Recent Searches
 
     private var recentSearchesSection: some View {
@@ -108,6 +152,110 @@ struct ContentView: View {
         .padding(.top, 8)
     }
 
+    // MARK: - Reachability
+
+    private var reachabilitySection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            sectionHeader("Reachability")
+
+            if viewModel.reachabilityLoading {
+                ProgressView("Checking ports…")
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .padding()
+            } else if let error = viewModel.reachabilityError {
+                errorLabel(error)
+            } else {
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(viewModel.reachabilityResults) { result in
+                        HStack(spacing: 8) {
+                            Circle()
+                                .fill(result.reachable ? Color.green : Color.red)
+                                .frame(width: 8, height: 8)
+                            Text("Port \(result.port)")
+                                .font(.system(.caption, design: .monospaced))
+                            if result.reachable, let ms = result.latencyMs {
+                                Text("\(ms)ms")
+                                    .font(.system(.caption, design: .monospaced))
+                                    .foregroundStyle(.secondary)
+                            } else if !result.reachable {
+                                Text("—")
+                                    .font(.system(.caption, design: .monospaced))
+                                    .foregroundStyle(.secondary)
+                            }
+                            Spacer()
+                            Text(result.reachable ? "Reachable" : "Unreachable")
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(result.reachable ? .green : .red)
+                        }
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 8)
+    }
+
+    // MARK: - Redirect Chain
+
+    private var redirectChainSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            sectionHeader("Redirect Chain")
+
+            if viewModel.redirectChainLoading {
+                ProgressView("Tracing redirects…")
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .padding()
+            } else if let error = viewModel.redirectChainError {
+                errorLabel(error)
+            } else if viewModel.redirectChain.isEmpty {
+                Text("No redirect data")
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(.secondary)
+                    .padding(8)
+            } else if viewModel.redirectChain.count == 1,
+                      let only = viewModel.redirectChain.first,
+                      only.isFinal, !(300...399).contains(only.statusCode) {
+                Text("No redirects — direct connection")
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(.secondary)
+                    .padding(10)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .background(Color(.systemGray6).opacity(0.5))
+                    .cornerRadius(6)
+            } else {
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(viewModel.redirectChain) { hop in
+                        HStack(alignment: .top, spacing: 6) {
+                            Text("\(hop.stepNumber)")
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.secondary)
+                                .frame(width: 16, alignment: .trailing)
+                            Text("\(hop.statusCode)")
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.cyan)
+                                .frame(width: 30, alignment: .leading)
+                            Text(hop.url)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.primary)
+                                .textSelection(.enabled)
+                            if hop.isFinal {
+                                Text("(final)")
+                                    .font(.system(.caption2, design: .monospaced))
+                                    .foregroundStyle(.secondary)
+                            }
+                        }
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
     // MARK: - DNS Results
 
     private var dnsResultsSection: some View {
@@ -123,10 +271,40 @@ struct ContentView: View {
             } else {
                 ForEach(viewModel.dnsSections) { section in
                     dnsRecordSection(section)
+                    if section.recordType == .A {
+                        ptrRow
+                    }
                 }
             }
         }
-        .padding(.top, 8)
+        .padding(.top, 16)
+    }
+
+    private var ptrRow: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text("PTR (Reverse DNS)")
+                .font(.system(.subheadline, design: .monospaced))
+                .fontWeight(.semibold)
+                .foregroundStyle(.cyan)
+
+            if viewModel.ptrLoading {
+                ProgressView()
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .padding(4)
+            } else if let ptr = viewModel.ptrRecord {
+                Text(ptr)
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(.primary)
+                    .textSelection(.enabled)
+            } else {
+                Text("No PTR record found")
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+        }
+        .padding(10)
+        .background(Color(.systemGray6).opacity(0.5))
+        .cornerRadius(6)
     }
 
     private func dnsRecordSection(_ section: DNSSection) -> some View {
@@ -146,7 +324,6 @@ struct ContentView: View {
                 dnsRecordRows(section.records)
             }
 
-            // Wildcard sub-section (only shown when records exist)
             if !section.wildcardRecords.isEmpty {
                 Text("*.\(viewModel.searchedDomain)")
                     .font(.system(.caption, design: .monospaced))
@@ -177,6 +354,66 @@ struct ContentView: View {
         }
     }
 
+    // MARK: - Email Security
+
+    @State private var expandedEmailField: String?
+
+    private var emailSecuritySection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            sectionHeader("Email Security")
+
+            if viewModel.emailSecurityLoading {
+                ProgressView("Checking email records…")
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .padding()
+            } else if let error = viewModel.emailSecurityError {
+                errorLabel(error)
+            } else if let email = viewModel.emailSecurity {
+                VStack(alignment: .leading, spacing: 6) {
+                    emailSecurityRow("SPF", record: email.spf)
+                    emailSecurityRow("DMARC", record: email.dmarc)
+                    emailSecurityRow("DKIM", record: email.dkim)
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View {
+        VStack(alignment: .leading, spacing: 2) {
+            HStack(spacing: 8) {
+                Text(label)
+                    .font(.system(.caption, design: .monospaced))
+                    .fontWeight(.semibold)
+                    .frame(width: 52, alignment: .leading)
+                Text(record.found ? "✓" : "✗")
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(record.found ? .green : .red)
+                if let value = record.value {
+                    let isExpanded = expandedEmailField == label
+                    let displayValue = isExpanded ? value : String(value.prefix(80))
+                    Text(displayValue)
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.primary)
+                        .textSelection(.enabled)
+                        .lineLimit(isExpanded ? nil : 1)
+                        .onTapGesture {
+                            withAnimation {
+                                expandedEmailField = isExpanded ? nil : label
+                            }
+                        }
+                } else {
+                    Text("No record found")
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                }
+            }
+        }
+    }
+
     // MARK: - SSL Results
 
     private var sslResultsSection: some View {
@@ -201,7 +438,6 @@ struct ContentView: View {
             certRow("Common Name", info.commonName)
             certRow("Issuer", info.issuer)
 
-            // SANs
             VStack(alignment: .leading, spacing: 2) {
                 Text("SANs")
                     .font(.system(.caption2, design: .monospaced))
@@ -235,6 +471,136 @@ struct ContentView: View {
         .cornerRadius(6)
     }
 
+    // MARK: - HTTP Headers
+
+    private var httpHeadersSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            sectionHeader("HTTP Headers")
+
+            if viewModel.httpHeadersLoading {
+                ProgressView("Fetching headers…")
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .padding()
+            } else if let error = viewModel.httpHeadersError {
+                errorLabel(error)
+            } else {
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(viewModel.httpHeaders) { header in
+                        HStack(alignment: .top, spacing: 4) {
+                            Text(header.name + ":")
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
+                            Text(header.value)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.primary)
+                                .textSelection(.enabled)
+                        }
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    // MARK: - IP Geolocation
+
+    private var ipGeolocationSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            sectionHeader("IP Location")
+
+            if viewModel.ipGeolocationLoading {
+                ProgressView("Looking up location…")
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .padding()
+            } else if let geo = viewModel.ipGeolocation {
+                ipGeolocationDetail(geo)
+            } else if let error = viewModel.ipGeolocationError {
+                if error == "No A record available" {
+                    Text("No location data available")
+                        .font(.system(.caption, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                        .padding(8)
+                } else {
+                    errorLabel(error)
+                }
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
+        VStack(alignment: .leading, spacing: 6) {
+            certRow("IP", geo.ip)
+            if let org = geo.org {
+                certRow("Org / ISP", org)
+            }
+            let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
+            if !location.isEmpty {
+                certRow("Location", location)
+            }
+            if let lat = geo.latitude, let lon = geo.longitude {
+                let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
+                Map(initialPosition: .region(MKCoordinateRegion(
+                    center: coordinate,
+                    span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
+                ))) {
+                    Marker(geo.ip, coordinate: coordinate)
+                }
+                .mapStyle(.standard)
+                .frame(height: 180)
+                .cornerRadius(8)
+            }
+        }
+        .padding(10)
+        .background(Color(.systemGray6).opacity(0.5))
+        .cornerRadius(6)
+    }
+
+    // MARK: - Port Scan
+
+    private var portScanSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            sectionHeader("Open Ports")
+
+            if viewModel.portScanLoading {
+                ProgressView("Scanning ports…")
+                    .frame(maxWidth: .infinity, alignment: .center)
+                    .padding()
+            } else if let error = viewModel.portScanError {
+                errorLabel(error)
+            } else {
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(viewModel.portScanResults) { result in
+                        HStack(spacing: 8) {
+                            Circle()
+                                .fill(result.open ? Color.green : Color(.systemGray4))
+                                .frame(width: 8, height: 8)
+                            Text("\(result.port)")
+                                .font(.system(.caption, design: .monospaced))
+                                .frame(width: 44, alignment: .leading)
+                            Text(result.service)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(result.open ? .primary : .secondary)
+                            Spacer()
+                            if result.open {
+                                Text("Open")
+                                    .font(.system(.caption2, design: .monospaced))
+                                    .foregroundStyle(.green)
+                            }
+                        }
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
     // MARK: - Helpers
 
     private func sectionHeader(_ title: String) -> some View {
@@ -269,8 +635,6 @@ struct ContentView: View {
 
     private func shareResults() {
         let text = viewModel.exportText()
-
-        // Write to a named temp file so "Save to Files" uses a proper filename
         let dateFmt = DateFormatter()
         dateFmt.dateFormat = "yyyyMMdd_HHmmss"
         let timestamp = dateFmt.string(from: Date())
@@ -295,7 +659,7 @@ struct ContentView: View {
     }
 }
 
-private extension DateFormatter {
+extension DateFormatter {
     static let certDate: DateFormatter = {
         let f = DateFormatter()
         f.dateStyle = .medium
diff --git a/DomainDig/DNSLookupService.swift b/DomainDig/DNSLookupService.swift
index 85a5275..a8f55fd 100644
--- a/DomainDig/DNSLookupService.swift
+++ b/DomainDig/DNSLookupService.swift
@@ -35,15 +35,16 @@ struct DNSLookupService {
             }
     }
 
-    /// Record types that support wildcard queries.
-    private nonisolated(unsafe) static let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
-
     static func lookupAll(domain: String) async -> [DNSSection] {
         // Each task returns (recordType, apex records, wildcard records).
         typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?)
 
+        // Record types that support wildcard queries
+        let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
+
         return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
             for recordType in DNSRecordType.allCases {
+                let shouldQueryWildcard = wildcardTypes.contains(recordType)
                 group.addTask {
                     var apexRecords: [DNSRecord] = []
                     var wildcardRecords: [DNSRecord] = []
@@ -57,7 +58,7 @@ struct DNSLookupService {
                     }
 
                     // Wildcard query (only for applicable types, and only if apex didn't fail)
-                    if wildcardTypes.contains(recordType) && lookupError == nil {
+                    if shouldQueryWildcard && lookupError == nil {
                         do {
                             wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType)
                         } catch {
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 2965dde..afb7e3c 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -6,22 +6,135 @@ import SwiftUI
 final class DomainViewModel {
     var domain: String = ""
 
+    // DNS
     var dnsSections: [DNSSection] = []
     var dnsLoading = false
     var dnsError: String?
 
+    // SSL
     var sslInfo: SSLCertificateInfo?
     var sslLoading = false
     var sslError: String?
 
+    // HTTP Headers
+    var httpHeaders: [HTTPHeader] = []
+    var httpHeadersLoading = false
+    var httpHeadersError: String?
+
+    // Reachability
+    var reachabilityResults: [PortReachability] = []
+    var reachabilityLoading = false
+    var reachabilityError: String?
+
+    // IP Geolocation
+    var ipGeolocation: IPGeolocation?
+    var ipGeolocationLoading = false
+    var ipGeolocationError: String?
+
+    // Email Security
+    var emailSecurity: EmailSecurityResult?
+    var emailSecurityLoading = false
+    var emailSecurityError: String?
+
+    // PTR / Reverse DNS
+    var ptrRecord: String?
+    var ptrLoading = false
+    var ptrError: String?
+
+    // Redirect Chain
+    var redirectChain: [RedirectHop] = []
+    var redirectChainLoading = false
+    var redirectChainError: String?
+
+    // Port Scan
+    var portScanResults: [PortScanResult] = []
+    var portScanLoading = false
+    var portScanError: String?
+
     var hasRun = false
     private(set) var searchedDomain: String = ""
 
+    // MARK: - Recent Searches
+
     private static let recentSearchesKey = "recentSearches"
     private static let maxRecent = 20
 
     var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
 
+    // MARK: - Saved Domains
+
+    private static let savedDomainsKey = "savedDomains"
+
+    var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
+
+    var isCurrentDomainSaved: Bool {
+        !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
+    }
+
+    func toggleSavedDomain() {
+        if isCurrentDomainSaved {
+            savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
+        } else {
+            savedDomains.append(searchedDomain)
+        }
+        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+    }
+
+    func removeSavedDomain(_ domain: String) {
+        savedDomains.removeAll { $0 == domain }
+        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+    }
+
+    func removeSavedDomains(at offsets: IndexSet) {
+        savedDomains.remove(atOffsets: offsets)
+        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+    }
+
+    // MARK: - History
+
+    private static let historyKey = "lookupHistory"
+    private static let maxHistory = 50
+
+    var history: [HistoryEntry] = {
+        guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
+              let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
+            return []
+        }
+        return entries
+    }()
+
+    private func saveHistoryEntry() {
+        let entry = HistoryEntry(
+            domain: searchedDomain,
+            timestamp: Date(),
+            dnsSections: dnsSections,
+            sslInfo: sslInfo,
+            httpHeaders: httpHeaders,
+            reachabilityResults: reachabilityResults,
+            ipGeolocation: ipGeolocation,
+            emailSecurity: emailSecurity,
+            ptrRecord: ptrRecord,
+            redirectChain: redirectChain,
+            portScanResults: portScanResults
+        )
+        history.insert(entry, at: 0)
+        if history.count > Self.maxHistory {
+            history = Array(history.prefix(Self.maxHistory))
+        }
+        if let data = try? JSONEncoder().encode(history) {
+            UserDefaults.standard.set(data, forKey: Self.historyKey)
+        }
+    }
+
+    func removeHistoryEntries(at offsets: IndexSet) {
+        history.remove(atOffsets: offsets)
+        if let data = try? JSONEncoder().encode(history) {
+            UserDefaults.standard.set(data, forKey: Self.historyKey)
+        }
+    }
+
+    // MARK: - Computed
+
     var trimmedDomain: String {
         domain
             .trimmingCharacters(in: .whitespacesAndNewlines)
@@ -30,6 +143,49 @@ final class DomainViewModel {
             .components(separatedBy: "/").first ?? ""
     }
 
+    /// True when all lookups have finished (regardless of success/failure).
+    var resultsLoaded: Bool {
+        hasRun && !dnsLoading && !sslLoading && !httpHeadersLoading && !reachabilityLoading
+            && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
+            && !redirectChainLoading && !portScanLoading
+    }
+
+    // MARK: - Reset
+
+    func reset() {
+        hasRun = false
+        searchedDomain = ""
+        dnsSections = []
+        dnsError = nil
+        dnsLoading = false
+        sslInfo = nil
+        sslError = nil
+        sslLoading = false
+        httpHeaders = []
+        httpHeadersError = nil
+        httpHeadersLoading = false
+        reachabilityResults = []
+        reachabilityError = nil
+        reachabilityLoading = false
+        ipGeolocation = nil
+        ipGeolocationError = nil
+        ipGeolocationLoading = false
+        emailSecurity = nil
+        emailSecurityError = nil
+        emailSecurityLoading = false
+        ptrRecord = nil
+        ptrError = nil
+        ptrLoading = false
+        redirectChain = []
+        redirectChainError = nil
+        redirectChainLoading = false
+        portScanResults = []
+        portScanError = nil
+        portScanLoading = false
+    }
+
+    // MARK: - Run
+
     func run() {
         let target = trimmedDomain
         guard !target.isEmpty else { return }
@@ -37,25 +193,77 @@ final class DomainViewModel {
         addRecentSearch(target)
         searchedDomain = target
         hasRun = true
+
+        // Reset all state
         dnsSections = []
         dnsError = nil
         dnsLoading = true
         sslInfo = nil
         sslError = nil
         sslLoading = true
+        httpHeaders = []
+        httpHeadersError = nil
+        httpHeadersLoading = true
+        reachabilityResults = []
+        reachabilityError = nil
+        reachabilityLoading = true
+        ipGeolocation = nil
+        ipGeolocationError = nil
+        ipGeolocationLoading = true
+        emailSecurity = nil
+        emailSecurityError = nil
+        emailSecurityLoading = true
+        ptrRecord = nil
+        ptrError = nil
+        ptrLoading = true
+        redirectChain = []
+        redirectChainError = nil
+        redirectChainLoading = true
+        portScanResults = []
+        portScanError = nil
+        portScanLoading = true
 
         Task {
             await withTaskGroup(of: Void.self) { group in
+                // DNS → chained: email security, PTR, geolocation
                 group.addTask { @MainActor in
                     await self.runDNS(domain: target)
+                    // These depend on DNS results and run in parallel after DNS
+                    await withTaskGroup(of: Void.self) { postDNS in
+                        postDNS.addTask { @MainActor in
+                            await self.runEmailSecurity(domain: target)
+                        }
+                        postDNS.addTask { @MainActor in
+                            await self.runReverseDNS()
+                        }
+                        postDNS.addTask { @MainActor in
+                            await self.runIPGeolocation()
+                        }
+                    }
                 }
                 group.addTask { @MainActor in
                     await self.runSSL(domain: target)
                 }
+                group.addTask { @MainActor in
+                    await self.runHTTPHeaders(domain: target)
+                }
+                group.addTask { @MainActor in
+                    await self.runReachability(domain: target)
+                }
+                group.addTask { @MainActor in
+                    await self.runRedirectChain(domain: target)
+                }
+                group.addTask { @MainActor in
+                    await self.runPortScan(domain: target)
+                }
             }
+            // Save history after all lookups complete so the snapshot is complete
+            saveHistoryEntry()
         }
     }
 
+    // MARK: - Lookup Methods
+
     private func runDNS(domain: String) async {
         do {
             let sections = await DNSLookupService.lookupAll(domain: domain)
@@ -74,27 +282,157 @@ final class DomainViewModel {
         sslLoading = false
     }
 
-    /// True when both lookups have finished (regardless of success/failure).
-    var resultsLoaded: Bool {
-        hasRun && !dnsLoading && !sslLoading
+    private func runHTTPHeaders(domain: String) async {
+        do {
+            let headers = try await HTTPHeadersService.fetch(domain: domain)
+            httpHeaders = headers
+        } catch {
+            httpHeadersError = error.localizedDescription
+        }
+        httpHeadersLoading = false
+    }
+
+    private func runReachability(domain: String) async {
+        let results = await ReachabilityService.checkAll(domain: domain)
+        reachabilityResults = results
+        reachabilityLoading = false
+    }
+
+    private func runIPGeolocation() async {
+        // Find the first A record IP
+        guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
+              let firstIP = aSection.records.first?.value else {
+            ipGeolocationError = "No A record available"
+            ipGeolocationLoading = false
+            return
+        }
+        do {
+            let geo = try await IPGeolocationService.lookup(ip: firstIP)
+            ipGeolocation = geo
+        } catch {
+            ipGeolocationError = error.localizedDescription
+        }
+        ipGeolocationLoading = false
+    }
+
+    private func runEmailSecurity(domain: String) async {
+        // Extract TXT records from already-fetched DNS sections
+        let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
+        let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
+        emailSecurity = result
+        emailSecurityLoading = false
+    }
+
+    private func runReverseDNS() async {
+        guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
+              let firstIP = aSection.records.first?.value else {
+            ptrError = "No A record available"
+            ptrLoading = false
+            return
+        }
+        let result = await ReverseDNSService.lookup(ip: firstIP)
+        ptrRecord = result
+        if result == nil {
+            ptrError = "No PTR record found"
+        }
+        ptrLoading = false
+    }
+
+    private func runRedirectChain(domain: String) async {
+        do {
+            let hops = try await RedirectChainService.trace(domain: domain)
+            redirectChain = hops
+        } catch {
+            redirectChainError = error.localizedDescription
+        }
+        redirectChainLoading = false
+    }
+
+    private func runPortScan(domain: String) async {
+        let results = await PortScanService.scanAll(domain: domain)
+        portScanResults = results
+        portScanLoading = false
     }
 
     // MARK: - Export
 
     func exportText() -> String {
+        return Self.formatExportText(
+            domain: searchedDomain,
+            date: Date(),
+            dnsSections: dnsSections,
+            sslInfo: sslInfo,
+            sslError: sslError,
+            httpHeaders: httpHeaders,
+            httpHeadersError: httpHeadersError,
+            reachabilityResults: reachabilityResults,
+            ipGeolocation: ipGeolocation,
+            ipGeolocationError: ipGeolocationError,
+            emailSecurity: emailSecurity,
+            ptrRecord: ptrRecord,
+            redirectChain: redirectChain,
+            portScanResults: portScanResults
+        )
+    }
+
+    static func formatExportText(
+        domain: String,
+        date: Date,
+        dnsSections: [DNSSection],
+        sslInfo: SSLCertificateInfo?,
+        sslError: String? = nil,
+        httpHeaders: [HTTPHeader],
+        httpHeadersError: String? = nil,
+        reachabilityResults: [PortReachability],
+        ipGeolocation: IPGeolocation?,
+        ipGeolocationError: String? = nil,
+        emailSecurity: EmailSecurityResult? = nil,
+        ptrRecord: String? = nil,
+        redirectChain: [RedirectHop] = [],
+        portScanResults: [PortScanResult] = []
+    ) -> String {
         let dateFmt = DateFormatter()
         dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
-        let now = dateFmt.string(from: Date())
 
         var lines: [String] = [
             "DomainDig Export",
-            "Domain: \(searchedDomain)",
-            "Date: \(now)",
-            "",
-            "DNS Records",
-            "-----------"
+            "Domain: \(domain)",
+            "Date: \(dateFmt.string(from: date))",
         ]
 
+        // Reachability
+        if !reachabilityResults.isEmpty {
+            lines.append("")
+            lines.append("Reachability")
+            lines.append("------------")
+            for result in reachabilityResults {
+                if result.reachable, let ms = result.latencyMs {
+                    lines.append("  Port \(result.port)  \(ms)ms  Reachable")
+                } else {
+                    lines.append("  Port \(result.port)  —  Unreachable")
+                }
+            }
+        }
+
+        // Redirect Chain
+        if !redirectChain.isEmpty {
+            lines.append("")
+            lines.append("Redirect Chain")
+            lines.append("--------------")
+            if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) {
+                lines.append("  No redirects — direct connection")
+            } else {
+                for hop in redirectChain {
+                    let final = hop.isFinal ? "  (final)" : ""
+                    lines.append("  \(hop.stepNumber)  \(hop.statusCode)  \(hop.url)\(final)")
+                }
+            }
+        }
+
+        // DNS
+        lines.append("")
+        lines.append("DNS Records")
+        lines.append("-----------")
         for section in dnsSections {
             lines.append(section.recordType.rawValue)
             if let error = section.error {
@@ -107,13 +445,30 @@ final class DomainViewModel {
                 }
             }
             if !section.wildcardRecords.isEmpty {
-                lines.append("*.\(searchedDomain)")
+                lines.append("*.\(domain)")
                 for record in section.wildcardRecords {
                     lines.append("  \(record.value)  TTL \(record.ttl)")
                 }
             }
         }
 
+        // PTR
+        if let ptr = ptrRecord {
+            lines.append("PTR (Reverse DNS)")
+            lines.append("  \(ptr)")
+        }
+
+        // Email Security
+        if let email = emailSecurity {
+            lines.append("")
+            lines.append("Email Security")
+            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")")
+        }
+
+        // SSL
         if let info = sslInfo {
             let certDateFmt = DateFormatter()
             certDateFmt.dateStyle = .medium
@@ -136,6 +491,59 @@ final class DomainViewModel {
             lines.append("Error: \(error)")
         }
 
+        // HTTP Headers
+        if !httpHeaders.isEmpty {
+            lines.append("")
+            lines.append("HTTP Headers")
+            lines.append("------------")
+            for header in httpHeaders {
+                lines.append("  \(header.name): \(header.value)")
+            }
+        } else if let error = httpHeadersError {
+            lines.append("")
+            lines.append("HTTP Headers")
+            lines.append("------------")
+            lines.append("Error: \(error)")
+        }
+
+        // IP Geolocation
+        if let geo = ipGeolocation {
+            lines.append("")
+            lines.append("IP Location")
+            lines.append("-----------")
+            lines.append("IP: \(geo.ip)")
+            if let org = geo.org { lines.append("Org: \(org)") }
+            let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
+            if !location.isEmpty { lines.append("Location: \(location)") }
+            if let lat = geo.latitude, let lon = geo.longitude {
+                lines.append("Coordinates: \(lat), \(lon)")
+            }
+        } else if let error = ipGeolocationError, error != "No A record available" {
+            lines.append("")
+            lines.append("IP Location")
+            lines.append("-----------")
+            lines.append("Error: \(error)")
+        }
+
+        // Open Ports
+        if !portScanResults.isEmpty {
+            lines.append("")
+            lines.append("Open Ports")
+            lines.append("----------")
+            let openPorts = portScanResults.filter { $0.open }
+            if openPorts.isEmpty {
+                lines.append("  No open ports detected")
+            } else {
+                for port in openPorts {
+                    lines.append("  \(port.port)  \(port.service)")
+                }
+            }
+            let closedPorts = portScanResults.filter { !$0.open }
+            if !closedPorts.isEmpty {
+                lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))")
+            }
+        }
+
         return lines.joined(separator: "\n")
     }
 
diff --git a/DomainDig/EmailSecurityService.swift b/DomainDig/EmailSecurityService.swift
new file mode 100644
index 0000000..8922993
--- /dev/null
+++ b/DomainDig/EmailSecurityService.swift
@@ -0,0 +1,61 @@
+import Foundation
+
+struct EmailSecurityService {
+    /// 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 {
+        // SPF: extract from existing TXT records
+        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
+        async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)")
+        async let dkimResult = queryDKIM(domain: domain)
+
+        let dmarcValue = await dmarcResult
+        let dkimValue = await dkimResult
+
+        let dmarc = EmailSecurityRecord(
+            found: dmarcValue != nil,
+            value: dmarcValue
+        )
+        let dkim = EmailSecurityRecord(
+            found: dkimValue != nil,
+            value: dkimValue
+        )
+
+        return EmailSecurityResult(spf: spf, dmarc: dmarc, dkim: dkim)
+    }
+
+    /// Query a TXT record for the given subdomain via DoH.
+    private static func queryTXT(subdomain: String) async -> String? {
+        do {
+            let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
+            return records.first?.value
+        } catch {
+            return nil
+        }
+    }
+
+    /// 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() {
+                group.addTask {
+                    let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)")
+                    return (index, value)
+                }
+            }
+
+            var results: [(Int, String?)] = []
+            for await result in group {
+                results.append(result)
+            }
+            // Return the first (by selector order) that has a value
+            return results
+                .sorted { $0.0 < $1.0 }
+                .first(where: { $0.1 != nil })?.1
+        }
+    }
+}
diff --git a/DomainDig/HTTPHeadersService.swift b/DomainDig/HTTPHeadersService.swift
new file mode 100644
index 0000000..a297165
--- /dev/null
+++ b/DomainDig/HTTPHeadersService.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+struct HTTPHeadersService {
+    static func fetch(domain: String) async throws -> [HTTPHeader] {
+        let url = URL(string: "https://\(domain)")!
+        var request = URLRequest(url: url, timeoutInterval: 10)
+        request.httpMethod = "HEAD"
+
+        let (_, response) = try await URLSession.shared.data(for: request)
+
+        guard let httpResponse = response as? HTTPURLResponse else {
+            throw URLError(.badServerResponse)
+        }
+
+        return httpResponse.allHeaderFields.compactMap { key, value in
+            guard let name = key as? String,
+                  let val = value as? String else { return nil }
+            return HTTPHeader(name: name, value: val)
+        }
+        .sorted { $0.name.lowercased() < $1.name.lowercased() }
+    }
+}
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
new file mode 100644
index 0000000..b64ebf3
--- /dev/null
+++ b/DomainDig/HistoryView.swift
@@ -0,0 +1,496 @@
+import SwiftUI
+import MapKit
+
+struct HistoryView: View {
+    @Bindable var viewModel: DomainViewModel
+    @Environment(\.dismiss) private var dismiss
+
+    private let dateFmt: DateFormatter = {
+        let f = DateFormatter()
+        f.dateStyle = .medium
+        f.timeStyle = .short
+        return f
+    }()
+
+    var body: some View {
+        List {
+            if viewModel.history.isEmpty {
+                Text("No lookup history")
+                    .font(.system(.callout, design: .monospaced))
+                    .foregroundStyle(.secondary)
+                    .listRowBackground(Color(.systemGray6).opacity(0.5))
+            } else {
+                ForEach(viewModel.history) { entry in
+                    NavigationLink {
+                        HistoryDetailView(entry: entry)
+                    } label: {
+                        VStack(alignment: .leading, spacing: 2) {
+                            Text(entry.domain)
+                                .font(.system(.callout, design: .monospaced))
+                                .foregroundStyle(.primary)
+                            Text(dateFmt.string(from: entry.timestamp))
+                                .font(.system(.caption2, design: .monospaced))
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+                    .listRowBackground(Color(.systemGray6).opacity(0.5))
+                }
+                .onDelete { offsets in
+                    viewModel.removeHistoryEntries(at: offsets)
+                }
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .navigationTitle("History")
+        .toolbar {
+            if !viewModel.history.isEmpty {
+                EditButton()
+            }
+        }
+        .preferredColorScheme(.dark)
+    }
+}
+
+// MARK: - History Detail View (Read-Only Cached Results)
+
+struct HistoryDetailView: View {
+    let entry: HistoryEntry
+
+    private let dateFmt: DateFormatter = {
+        let f = DateFormatter()
+        f.dateStyle = .medium
+        f.timeStyle = .short
+        return f
+    }()
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 0) {
+                cachedBanner
+                reachabilitySection
+                redirectChainSection
+                dnsSection
+                emailSecuritySection
+                sslSection
+                httpHeadersSection
+                ipGeolocationSection
+                portScanSection
+            }
+            .padding(.horizontal)
+            .padding(.bottom, 32)
+        }
+        .background(Color.black)
+        .navigationTitle(entry.domain)
+        .preferredColorScheme(.dark)
+    }
+
+    // MARK: - Cached Banner
+
+    private var cachedBanner: some View {
+        HStack(spacing: 6) {
+            Image(systemName: "archivebox")
+                .font(.caption)
+            Text("Cached result from \(dateFmt.string(from: entry.timestamp))")
+                .font(.system(.caption, design: .monospaced))
+        }
+        .foregroundStyle(.secondary)
+        .padding(8)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(Color(.systemGray6).opacity(0.3))
+        .cornerRadius(6)
+        .padding(.vertical, 12)
+    }
+
+    // MARK: - Reachability
+
+    private var reachabilitySection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            if !entry.reachabilityResults.isEmpty {
+                sectionHeader("Reachability")
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(entry.reachabilityResults) { result in
+                        HStack(spacing: 8) {
+                            Circle()
+                                .fill(result.reachable ? Color.green : Color.red)
+                                .frame(width: 8, height: 8)
+                            Text("Port \(result.port)")
+                                .font(.system(.caption, design: .monospaced))
+                            if result.reachable, let ms = result.latencyMs {
+                                Text("\(ms)ms")
+                                    .font(.system(.caption, design: .monospaced))
+                                    .foregroundStyle(.secondary)
+                            } else if !result.reachable {
+                                Text("—")
+                                    .font(.system(.caption, design: .monospaced))
+                                    .foregroundStyle(.secondary)
+                            }
+                            Spacer()
+                            Text(result.reachable ? "Reachable" : "Unreachable")
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(result.reachable ? .green : .red)
+                        }
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 8)
+    }
+
+    // MARK: - Redirect Chain
+
+    private var redirectChainSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            if !entry.redirectChain.isEmpty {
+                sectionHeader("Redirect Chain")
+                if entry.redirectChain.count == 1,
+                   let only = entry.redirectChain.first,
+                   only.isFinal, !(300...399).contains(only.statusCode) {
+                    Text("No redirects — direct connection")
+                        .font(.system(.caption, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                        .padding(10)
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                        .background(Color(.systemGray6).opacity(0.5))
+                        .cornerRadius(6)
+                } else {
+                    VStack(alignment: .leading, spacing: 4) {
+                        ForEach(entry.redirectChain) { hop in
+                            HStack(alignment: .top, spacing: 6) {
+                                Text("\(hop.stepNumber)")
+                                    .font(.system(.caption, design: .monospaced))
+                                    .foregroundStyle(.secondary)
+                                    .frame(width: 16, alignment: .trailing)
+                                Text("\(hop.statusCode)")
+                                    .font(.system(.caption, design: .monospaced))
+                                    .foregroundStyle(.cyan)
+                                    .frame(width: 30, alignment: .leading)
+                                Text(hop.url)
+                                    .font(.system(.caption, design: .monospaced))
+                                    .foregroundStyle(.primary)
+                                    .textSelection(.enabled)
+                                if hop.isFinal {
+                                    Text("(final)")
+                                        .font(.system(.caption2, design: .monospaced))
+                                        .foregroundStyle(.secondary)
+                                }
+                            }
+                        }
+                    }
+                    .padding(10)
+                    .background(Color(.systemGray6).opacity(0.5))
+                    .cornerRadius(6)
+                }
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    // MARK: - DNS
+
+    private var dnsSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            sectionHeader("DNS Records")
+            ForEach(entry.dnsSections) { section in
+                VStack(alignment: .leading, spacing: 4) {
+                    Text(section.recordType.rawValue)
+                        .font(.system(.subheadline, design: .monospaced))
+                        .fontWeight(.semibold)
+                        .foregroundStyle(.cyan)
+
+                    if let error = section.error {
+                        errorLabel(error)
+                    } else if section.records.isEmpty {
+                        Text("No records found")
+                            .font(.system(.caption, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                    } else {
+                        recordRows(section.records)
+                    }
+
+                    if !section.wildcardRecords.isEmpty {
+                        Text("*.\(entry.domain)")
+                            .font(.system(.caption, design: .monospaced))
+                            .fontWeight(.medium)
+                            .foregroundStyle(.cyan.opacity(0.7))
+                            .padding(.top, 4)
+                        recordRows(section.wildcardRecords)
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+
+                if section.recordType == .A {
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text("PTR (Reverse DNS)")
+                            .font(.system(.subheadline, design: .monospaced))
+                            .fontWeight(.semibold)
+                            .foregroundStyle(.cyan)
+
+                        if let ptr = entry.ptrRecord {
+                            Text(ptr)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.primary)
+                                .textSelection(.enabled)
+                        } else {
+                            Text("No PTR record found")
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.secondary)
+                        }
+                    }
+                    .padding(10)
+                    .background(Color(.systemGray6).opacity(0.5))
+                    .cornerRadius(6)
+                }
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    // MARK: - Email Security
+
+    @State private var expandedEmailField: String?
+
+    private var emailSecuritySection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            if let email = entry.emailSecurity {
+                sectionHeader("Email Security")
+                VStack(alignment: .leading, spacing: 6) {
+                    historyEmailRow("SPF", record: email.spf)
+                    historyEmailRow("DMARC", record: email.dmarc)
+                    historyEmailRow("DKIM", record: email.dkim)
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View {
+        VStack(alignment: .leading, spacing: 2) {
+            HStack(spacing: 8) {
+                Text(label)
+                    .font(.system(.caption, design: .monospaced))
+                    .fontWeight(.semibold)
+                    .frame(width: 52, alignment: .leading)
+                Text(record.found ? "✓" : "✗")
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(record.found ? .green : .red)
+                if let value = record.value {
+                    let isExpanded = expandedEmailField == label
+                    let displayValue = isExpanded ? value : String(value.prefix(80))
+                    Text(displayValue)
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.primary)
+                        .textSelection(.enabled)
+                        .lineLimit(isExpanded ? nil : 1)
+                        .onTapGesture {
+                            withAnimation {
+                                expandedEmailField = isExpanded ? nil : label
+                            }
+                        }
+                } else {
+                    Text("No record found")
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                }
+            }
+        }
+    }
+
+    // MARK: - SSL
+
+    private var sslSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            if let info = entry.sslInfo {
+                sectionHeader("SSL / TLS Certificate")
+                VStack(alignment: .leading, spacing: 8) {
+                    labelRow("Common Name", info.commonName)
+                    labelRow("Issuer", info.issuer)
+
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text("SANs")
+                            .font(.system(.caption2, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                        ForEach(info.subjectAltNames, id: \.self) { san in
+                            Text(san)
+                                .font(.system(.caption, design: .monospaced))
+                                .textSelection(.enabled)
+                        }
+                    }
+
+                    labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom))
+                    labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil))
+
+                    HStack {
+                        Text("Days Until Expiry")
+                            .font(.system(.caption2, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                        Spacer()
+                        Text("\(info.daysUntilExpiry)")
+                            .font(.system(.caption, design: .monospaced))
+                            .fontWeight(.bold)
+                            .foregroundStyle(expiryColor(info.daysUntilExpiry))
+                    }
+
+                    labelRow("Chain Depth", "\(info.chainDepth)")
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    // MARK: - HTTP Headers
+
+    private var httpHeadersSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            if !entry.httpHeaders.isEmpty {
+                sectionHeader("HTTP Headers")
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(entry.httpHeaders) { header in
+                        HStack(alignment: .top, spacing: 4) {
+                            Text(header.name + ":")
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
+                            Text(header.value)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.primary)
+                                .textSelection(.enabled)
+                        }
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    // MARK: - IP Geolocation
+
+    private var ipGeolocationSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            if let geo = entry.ipGeolocation {
+                sectionHeader("IP Location")
+                VStack(alignment: .leading, spacing: 6) {
+                    labelRow("IP", geo.ip)
+                    if let org = geo.org {
+                        labelRow("Org / ISP", org)
+                    }
+                    let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
+                    if !location.isEmpty {
+                        labelRow("Location", location)
+                    }
+                    if let lat = geo.latitude, let lon = geo.longitude {
+                        let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
+                        Map(initialPosition: .region(MKCoordinateRegion(
+                            center: coordinate,
+                            span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
+                        ))) {
+                            Marker(geo.ip, coordinate: coordinate)
+                        }
+                        .mapStyle(.standard)
+                        .frame(height: 180)
+                        .cornerRadius(8)
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    // MARK: - Port Scan
+
+    private var portScanSection: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            if !entry.portScanResults.isEmpty {
+                sectionHeader("Open Ports")
+                VStack(alignment: .leading, spacing: 4) {
+                    ForEach(entry.portScanResults) { result in
+                        HStack(spacing: 8) {
+                            Circle()
+                                .fill(result.open ? Color.green : Color(.systemGray4))
+                                .frame(width: 8, height: 8)
+                            Text("\(result.port)")
+                                .font(.system(.caption, design: .monospaced))
+                                .frame(width: 44, alignment: .leading)
+                            Text(result.service)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(result.open ? .primary : .secondary)
+                            Spacer()
+                            if result.open {
+                                Text("Open")
+                                    .font(.system(.caption2, design: .monospaced))
+                                    .foregroundStyle(.green)
+                            }
+                        }
+                    }
+                }
+                .padding(10)
+                .background(Color(.systemGray6).opacity(0.5))
+                .cornerRadius(6)
+            }
+        }
+        .padding(.top, 16)
+    }
+
+    // MARK: - Helpers
+
+    private func sectionHeader(_ title: String) -> some View {
+        Text(title)
+            .font(.system(.headline, design: .default))
+            .foregroundStyle(.white)
+    }
+
+    private func labelRow(_ label: String, _ value: String) -> some View {
+        VStack(alignment: .leading, spacing: 2) {
+            Text(label)
+                .font(.system(.caption2, design: .monospaced))
+                .foregroundStyle(.secondary)
+            Text(value)
+                .font(.system(.caption, design: .monospaced))
+                .textSelection(.enabled)
+        }
+    }
+
+    private func recordRows(_ records: [DNSRecord]) -> some View {
+        ForEach(records) { record in
+            HStack(alignment: .top) {
+                Text(record.value)
+                    .font(.system(.caption, design: .monospaced))
+                    .foregroundStyle(.primary)
+                    .textSelection(.enabled)
+                Spacer()
+                Text("TTL \(record.ttl)")
+                    .font(.system(.caption2, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+        }
+    }
+
+    private func errorLabel(_ message: String) -> some View {
+        Label(message, systemImage: "exclamationmark.triangle.fill")
+            .font(.system(.caption, design: .monospaced))
+            .foregroundStyle(.red)
+            .padding(8)
+    }
+
+    private func expiryColor(_ days: Int) -> Color {
+        if days < 30 { return .red }
+        if days < 60 { return .yellow }
+        return .green
+    }
+}
diff --git a/DomainDig/IPGeolocationService.swift b/DomainDig/IPGeolocationService.swift
new file mode 100644
index 0000000..6c60295
--- /dev/null
+++ b/DomainDig/IPGeolocationService.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+struct IPGeolocationService {
+    static func lookup(ip: String) async throws -> IPGeolocation {
+        let url = URL(string: "https://ipapi.co/\(ip)/json/")!
+        let request = URLRequest(url: url, timeoutInterval: 10)
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+
+        guard let httpResponse = response as? HTTPURLResponse,
+              httpResponse.statusCode == 200 else {
+            throw URLError(.badServerResponse)
+        }
+
+        return try JSONDecoder().decode(IPGeolocation.self, from: data)
+    }
+}
diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist
new file mode 100644
index 0000000..e397175
--- /dev/null
+++ b/DomainDig/Info.plist
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>Allow ​Arbitrary ​Loads (or NSAllows​Arbitrary​Loads) </key>
+	<true/>
+	<key>App ​Transport ​Security ​Settings (or NSApp​Transport​Security)</key>
+	<dict/>
+</dict>
+</plist>
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 53b3c39..88bcfa9 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -2,7 +2,7 @@ import Foundation
 
 // MARK: - DNS Models
 
-enum DNSRecordType: String, CaseIterable {
+enum DNSRecordType: String, CaseIterable, Codable {
     case A
     case AAAA
     case MX
@@ -22,14 +22,14 @@ enum DNSRecordType: String, CaseIterable {
     }
 }
 
-struct DNSRecord: Identifiable {
-    let id = UUID()
+struct DNSRecord: Identifiable, Codable {
+    var id = UUID()
     let value: String
     let ttl: Int
 }
 
-struct DNSSection: Identifiable {
-    let id = UUID()
+struct DNSSection: Identifiable, Codable {
+    var id = UUID()
     let recordType: DNSRecordType
     var records: [DNSRecord]
     var wildcardRecords: [DNSRecord] = []
@@ -38,7 +38,7 @@ struct DNSSection: Identifiable {
 
 // MARK: - SSL Models
 
-struct SSLCertificateInfo {
+struct SSLCertificateInfo: Codable {
     let commonName: String
     let subjectAltNames: [String]
     let issuer: String
@@ -48,6 +48,130 @@ struct SSLCertificateInfo {
     let chainDepth: Int
 }
 
+// MARK: - HTTP Headers Models
+
+struct HTTPHeader: Identifiable, Codable {
+    var id = UUID()
+    let name: String
+    let value: String
+
+    static let securityHeaders: Set<String> = [
+        "strict-transport-security",
+        "x-frame-options",
+        "x-content-type-options",
+        "content-security-policy",
+        "referrer-policy"
+    ]
+
+    var isSecurityHeader: Bool {
+        Self.securityHeaders.contains(name.lowercased())
+    }
+}
+
+// MARK: - Reachability Models
+
+struct PortReachability: Identifiable, Codable {
+    var id = UUID()
+    let port: UInt16
+    let reachable: Bool
+    let latencyMs: Int?
+}
+
+// MARK: - IP Geolocation Models
+
+struct IPGeolocation: Codable {
+    let ip: String
+    let city: String?
+    let region: String?
+    let country_name: String?
+    let org: String?
+    let latitude: Double?
+    let longitude: Double?
+}
+
+// MARK: - Email Security Models
+
+struct EmailSecurityResult: Codable {
+    let spf: EmailSecurityRecord
+    let dmarc: EmailSecurityRecord
+    let dkim: EmailSecurityRecord
+}
+
+struct EmailSecurityRecord: Codable {
+    let found: Bool
+    let value: String?
+}
+
+// MARK: - Redirect Chain Models
+
+struct RedirectHop: Identifiable, Codable {
+    var id = UUID()
+    let stepNumber: Int
+    let statusCode: Int
+    let url: String
+    let isFinal: Bool
+}
+
+// MARK: - Port Scan Models
+
+struct PortScanResult: Identifiable, Codable {
+    var id = UUID()
+    let port: UInt16
+    let service: String
+    let open: Bool
+}
+
+// MARK: - History Models
+
+struct HistoryEntry: Identifiable, Codable {
+    var id = UUID()
+    let domain: String
+    let timestamp: Date
+    let dnsSections: [DNSSection]
+    let sslInfo: SSLCertificateInfo?
+    let httpHeaders: [HTTPHeader]
+    let reachabilityResults: [PortReachability]
+    let ipGeolocation: IPGeolocation?
+    var emailSecurity: EmailSecurityResult?
+    var ptrRecord: String?
+    var redirectChain: [RedirectHop]
+    var portScanResults: [PortScanResult]
+
+    init(domain: String, timestamp: Date, dnsSections: [DNSSection],
+         sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
+         reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
+         emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil,
+         redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = []) {
+        self.domain = domain
+        self.timestamp = timestamp
+        self.dnsSections = dnsSections
+        self.sslInfo = sslInfo
+        self.httpHeaders = httpHeaders
+        self.reachabilityResults = reachabilityResults
+        self.ipGeolocation = ipGeolocation
+        self.emailSecurity = emailSecurity
+        self.ptrRecord = ptrRecord
+        self.redirectChain = redirectChain
+        self.portScanResults = portScanResults
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+        domain = try container.decode(String.self, forKey: .domain)
+        timestamp = try container.decode(Date.self, forKey: .timestamp)
+        dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections)
+        sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
+        httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders)
+        reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults)
+        ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
+        emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
+        ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
+        redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
+        portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
+    }
+}
+
 // MARK: - Cloudflare DNS-over-HTTPS Response
 
 struct CloudflareDNSResponse: Decodable {
diff --git a/DomainDig/PortScanService.swift b/DomainDig/PortScanService.swift
new file mode 100644
index 0000000..63d0571
--- /dev/null
+++ b/DomainDig/PortScanService.swift
@@ -0,0 +1,93 @@
+import Foundation
+import Network
+
+struct PortScanService {
+    struct PortInfo: Sendable {
+        let port: UInt16
+        let service: String
+    }
+
+    static let ports: [PortInfo] = [
+        PortInfo(port: 21, service: "FTP"),
+        PortInfo(port: 22, service: "SSH"),
+        PortInfo(port: 25, service: "SMTP"),
+        PortInfo(port: 80, service: "HTTP"),
+        PortInfo(port: 443, service: "HTTPS"),
+        PortInfo(port: 587, service: "SMTP (TLS)"),
+        PortInfo(port: 3306, service: "MySQL"),
+        PortInfo(port: 5432, service: "PostgreSQL"),
+        PortInfo(port: 8080, service: "HTTP Alt"),
+        PortInfo(port: 8443, service: "HTTPS Alt"),
+    ]
+
+    static func scanAll(domain: String) async -> [PortScanResult] {
+        await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in
+            for info in ports {
+                group.addTask {
+                    let open = await probe(domain: domain, port: info.port)
+                    return PortScanResult(port: info.port, service: info.service, open: open)
+                }
+            }
+
+            var results: [PortScanResult] = []
+            for await result in group {
+                results.append(result)
+            }
+
+            // Sort by port number
+            return results.sorted { $0.port < $1.port }
+        }
+    }
+
+    private static func probe(domain: String, port: UInt16) async -> Bool {
+        await withCheckedContinuation { continuation in
+            let host = NWEndpoint.Host(domain)
+            let nwPort = NWEndpoint.Port(rawValue: port)!
+            let connection = NWConnection(host: host, port: nwPort, using: .tcp)
+            let context = ProbeContext(connection: connection, continuation: continuation)
+
+            connection.stateUpdateHandler = { state in
+                switch state {
+                case .ready:
+                    context.finish(open: true)
+                case .failed, .cancelled:
+                    context.finish(open: false)
+                default:
+                    break
+                }
+            }
+
+            let queue = DispatchQueue(label: "portscan.\(port)")
+            connection.start(queue: queue)
+
+            queue.asyncAfter(deadline: .now() + 3) {
+                context.finish(open: false)
+            }
+        }
+    }
+}
+
+private final class ProbeContext: @unchecked Sendable {
+    private let connection: NWConnection
+    private let continuation: CheckedContinuation<Bool, Never>
+    private let lock = NSLock()
+    private nonisolated(unsafe) var resumed = false
+
+    init(connection: NWConnection, continuation: CheckedContinuation<Bool, Never>) {
+        self.connection = connection
+        self.continuation = continuation
+    }
+
+    nonisolated func finish(open: Bool) {
+        lock.lock()
+        guard !resumed else {
+            lock.unlock()
+            return
+        }
+        resumed = true
+        lock.unlock()
+
+        connection.cancel()
+        continuation.resume(returning: open)
+    }
+}
diff --git a/DomainDig/ReachabilityService.swift b/DomainDig/ReachabilityService.swift
new file mode 100644
index 0000000..0bb30ae
--- /dev/null
+++ b/DomainDig/ReachabilityService.swift
@@ -0,0 +1,67 @@
+import Foundation
+import Network
+
+struct ReachabilityService {
+    static func check(domain: String, port: UInt16) async -> PortReachability {
+        await withCheckedContinuation { continuation in
+            let host = NWEndpoint.Host(domain)
+            let nwPort = NWEndpoint.Port(rawValue: port)!
+            let connection = NWConnection(host: host, port: nwPort, using: .tcp)
+            let context = ConnectionContext(port: port, connection: connection, continuation: continuation)
+
+            connection.stateUpdateHandler = { state in
+                switch state {
+                case .ready:
+                    context.finish(reachable: true)
+                case .failed, .cancelled:
+                    context.finish(reachable: false)
+                default:
+                    break
+                }
+            }
+
+            let queue = DispatchQueue(label: "reachability.\(port)")
+            connection.start(queue: queue)
+
+            queue.asyncAfter(deadline: .now() + 5) {
+                context.finish(reachable: false)
+            }
+        }
+    }
+
+    static func checkAll(domain: String) async -> [PortReachability] {
+        async let port443 = check(domain: domain, port: 443)
+        async let port80 = check(domain: domain, port: 80)
+        return await [port443, port80]
+    }
+}
+
+private final class ConnectionContext: @unchecked Sendable {
+    private let port: UInt16
+    private let connection: NWConnection
+    private let continuation: CheckedContinuation<PortReachability, Never>
+    private let start = CFAbsoluteTimeGetCurrent()
+    private let lock = NSLock()
+    private nonisolated(unsafe) var resumed = false
+
+    init(port: UInt16, connection: NWConnection, continuation: CheckedContinuation<PortReachability, Never>) {
+        self.port = port
+        self.connection = connection
+        self.continuation = continuation
+    }
+
+    nonisolated func finish(reachable: Bool) {
+        lock.lock()
+        guard !resumed else {
+            lock.unlock()
+            return
+        }
+        resumed = true
+        lock.unlock()
+
+        let elapsed = CFAbsoluteTimeGetCurrent() - start
+        let ms = reachable ? Int(elapsed * 1000) : nil
+        connection.cancel()
+        continuation.resume(returning: PortReachability(port: port, reachable: reachable, latencyMs: ms))
+    }
+}
diff --git a/DomainDig/RedirectChainService.swift b/DomainDig/RedirectChainService.swift
new file mode 100644
index 0000000..7a23a5e
--- /dev/null
+++ b/DomainDig/RedirectChainService.swift
@@ -0,0 +1,82 @@
+import Foundation
+
+struct RedirectChainService {
+    static func trace(domain: String) async throws -> [RedirectHop] {
+        // Try HTTPS first (avoids ATS issues), fall back to HTTP if it fails entirely
+        do {
+            return try await followChain(startingURL: URL(string: "https://\(domain)")!)
+        } catch {
+            return try await followChain(startingURL: URL(string: "http://\(domain)")!)
+        }
+    }
+
+    private static func followChain(startingURL: URL) async throws -> [RedirectHop] {
+        let delegate = NoRedirectDelegate()
+        let session = URLSession(
+            configuration: .ephemeral,
+            delegate: delegate,
+            delegateQueue: nil
+        )
+        defer { session.invalidateAndCancel() }
+
+        var hops: [RedirectHop] = []
+        var currentURL = startingURL
+        let maxRedirects = 10
+
+        for step in 1...maxRedirects + 1 {
+            var request = URLRequest(url: currentURL, timeoutInterval: 10)
+            request.httpMethod = "GET"
+
+            let (_, response) = try await session.data(for: request)
+
+            guard let httpResponse = response as? HTTPURLResponse else {
+                throw URLError(.badServerResponse)
+            }
+
+            let statusCode = httpResponse.statusCode
+            let isRedirect = (300...399).contains(statusCode)
+
+            if isRedirect, let location = httpResponse.value(forHTTPHeaderField: "Location") {
+                hops.append(RedirectHop(
+                    stepNumber: step,
+                    statusCode: statusCode,
+                    url: currentURL.absoluteString,
+                    isFinal: false
+                ))
+
+                // Resolve relative redirects
+                if let nextURL = URL(string: location, relativeTo: currentURL)?.absoluteURL {
+                    currentURL = nextURL
+                } else {
+                    break
+                }
+
+                if step > maxRedirects { break }
+            } else {
+                // Non-redirect — this is the final destination
+                hops.append(RedirectHop(
+                    stepNumber: step,
+                    statusCode: statusCode,
+                    url: currentURL.absoluteString,
+                    isFinal: true
+                ))
+                break
+            }
+        }
+
+        return hops
+    }
+}
+
+private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
+    func urlSession(
+        _ session: URLSession,
+        task: URLSessionTask,
+        willPerformHTTPRedirection response: HTTPURLResponse,
+        newRequest request: URLRequest,
+        completionHandler: @escaping (URLRequest?) -> Void
+    ) {
+        // Don't follow redirects automatically — return nil to stop
+        completionHandler(nil)
+    }
+}
diff --git a/DomainDig/ReverseDNSService.swift b/DomainDig/ReverseDNSService.swift
new file mode 100644
index 0000000..caab0f7
--- /dev/null
+++ b/DomainDig/ReverseDNSService.swift
@@ -0,0 +1,48 @@
+import Foundation
+
+struct ReverseDNSService {
+    /// Look up the PTR record for an IPv4 address via Cloudflare DoH.
+    static func lookup(ip: String) async -> String? {
+        let octets = ip.split(separator: ".")
+        guard octets.count == 4 else { return nil }
+
+        let reversed = octets.reversed().joined(separator: ".")
+        let ptrDomain = "\(reversed).in-addr.arpa"
+
+        // PTR record type = 12
+        do {
+            let records = try await lookupPTR(domain: ptrDomain)
+            return records.first
+        } catch {
+            return nil
+        }
+    }
+
+    private static func lookupPTR(domain: String) async throws -> [String] {
+        var components = URLComponents(string: "https://cloudflare-dns.com/dns-query")!
+        components.queryItems = [
+            URLQueryItem(name: "name", value: domain),
+            URLQueryItem(name: "type", value: "12") // PTR
+        ]
+
+        var request = URLRequest(url: components.url!)
+        request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+
+        guard let httpResponse = response as? HTTPURLResponse,
+              httpResponse.statusCode == 200 else {
+            throw URLError(.badServerResponse)
+        }
+
+        let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
+
+        guard let answers = dnsResponse.Answer else {
+            return []
+        }
+
+        return answers
+            .filter { $0.type == 12 }
+            .map { $0.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
+    }
+}
diff --git a/DomainDig/SavedDomainsView.swift b/DomainDig/SavedDomainsView.swift
new file mode 100644
index 0000000..f407d78
--- /dev/null
+++ b/DomainDig/SavedDomainsView.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+
+struct SavedDomainsView: View {
+    @Bindable var viewModel: DomainViewModel
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        List {
+            if viewModel.savedDomains.isEmpty {
+                Text("No saved domains")
+                    .font(.system(.callout, design: .monospaced))
+                    .foregroundStyle(.secondary)
+                    .listRowBackground(Color(.systemGray6).opacity(0.5))
+            } else {
+                ForEach(viewModel.savedDomains, id: \.self) { domain in
+                    Button {
+                        viewModel.domain = domain
+                        dismiss()
+                        viewModel.run()
+                    } label: {
+                        Text(domain)
+                            .font(.system(.callout, design: .monospaced))
+                            .foregroundStyle(.primary)
+                    }
+                    .listRowBackground(Color(.systemGray6).opacity(0.5))
+                }
+                .onDelete { offsets in
+                    viewModel.removeSavedDomains(at: offsets)
+                }
+            }
+        }
+        .scrollContentBackground(.hidden)
+        .background(Color.black)
+        .navigationTitle("Saved Domains")
+        .toolbar {
+            if !viewModel.savedDomains.isEmpty {
+                EditButton()
+            }
+        }
+        .preferredColorScheme(.dark)
+    }
+}
diff --git a/claude.md b/claude.md
index 4a90660..bee59f1 100644
--- a/claude.md
+++ b/claude.md
@@ -1,9 +1,11 @@
 # DomainDig – Project Context
 
 ## What this is
-An iOS utility app for querying DNS records and inspecting SSL/TLS certificates
-for any domain. Designed for developers, sysadmins, and anyone who manages or
-troubleshoots domains and servers.
+An iOS utility app for querying DNS records, inspecting SSL/TLS certificates,
+checking HTTP headers, measuring TCP reachability, geolocating IP addresses,
+analyzing email security records, tracing redirect chains, performing reverse
+DNS lookups, and scanning common ports for any domain. Designed for developers,
+sysadmins, and anyone who manages or troubleshoots domains and servers.
 
 ## Target user
 Technically literate adults — developers, IT/sysadmin, homelab enthusiasts.
@@ -18,6 +20,8 @@ clean and information-dense, not hand-holding.
 - No onboarding, no tutorials, no splash screens
 - Fast — results should appear as soon as they're available, not after all
   lookups complete
+- Each result section loads independently with its own ProgressView
+- Errors shown inline per-section, never as global alerts
 
 ## Features
 
@@ -35,6 +39,10 @@ Display each record type in its own section. Show TTL alongside each result.
 If a record type returns no results, show "No records found" for that type
 rather than hiding the section entirely.
 
+After the apex query, also queries `*.{domain}` for A, AAAA, MX, and TXT.
+Wildcard results are shown as a sub-section beneath each record type, labelled
+with `*.{domain}`. Hidden if no wildcard records are returned.
+
 ### SSL/TLS Certificate Check
 Connect to the domain on port 443 via URLSession and inspect the server's
 certificate chain using URLSession delegate methods. Display:
@@ -45,19 +53,63 @@ certificate chain using URLSession delegate methods. Display:
 - Days until expiry — highlight in red if under 30 days, yellow if under 60
 - Certificate chain depth
 
+### HTTP Headers Check
+Fire a HEAD request to `https://{domain}` and display all response headers.
+Header names in cyan, values in primary. Security-relevant headers highlighted
+in yellow: `strict-transport-security`, `x-frame-options`,
+`x-content-type-options`, `content-security-policy`, `referrer-policy`.
+Service: `HTTPHeadersService.swift`.
+
+### Reachability / TCP Latency
+Use `NWConnection` (Network framework) to attempt TCP connections on ports 443
+and 80. Measure time to `.ready` state. Display green/red dot, latency in ms,
+and reachable/unreachable status. Timeout after 5 seconds.
+Service: `ReachabilityService.swift`.
+
+### IP Geolocation with Map
+After DNS A record resolves, take the first IP and query
+`https://ipapi.co/{ip}/json/` for country, region, city, org, lat/lon.
+Display in an "IP Location" section with a SwiftUI `Map` (MapKit) centered on
+the coordinates with a `Marker`. Map height 180pt, `.standard` style.
+Service: `IPGeolocationService.swift`.
+
+### Email Security Analysis
+Parse SPF from existing TXT records (no extra query). Query `_dmarc.{domain}`
+for DMARC and try common DKIM selectors (`default`, `google`, `mail`) via DoH.
+Display green checkmark if found, red ✗ if not. Full record values truncated
+to 80 chars with tap-to-expand. Triggered after DNS completes.
+Service: `EmailSecurityService.swift`.
+
+### Reverse DNS / PTR Lookup
+After DNS A record resolves, construct reverse DNS name (reversed octets +
+`.in-addr.arpa`) and query PTR record via Cloudflare DoH. Displayed inline
+in the DNS Records section below the A record sub-section.
+Service: `ReverseDNSService.swift`.
+
+### Redirect Chain
+Fire HTTP request to `http://{domain}` with redirects disabled. Follow up to
+10 redirects manually, recording each hop's URL and status code. Display step
+number, status code in cyan, URL, and "(final)" on the last hop. Shows
+"No redirects — direct connection" if the first request returns 200.
+Service: `RedirectChainService.swift`.
+
+### Common Port Scanner
+Probe 10 common ports (21/FTP, 22/SSH, 25/SMTP, 80/HTTP, 443/HTTPS,
+587/SMTP-TLS, 3306/MySQL, 5432/PostgreSQL, 8080/HTTP-Alt, 8443/HTTPS-Alt)
+using `NWConnection` with 3-second timeout. All probes run concurrently.
+Green dot for open, grey dot for closed. Closed is expected — no error shown.
+Service: `PortScanService.swift`.
+
 ### Results layout
 - Domain input at the top — large text field, keyboard shows on launch
-- Run button to trigger both DNS and SSL lookups simultaneously
-- DNS and SSL results displayed in clearly separated sections below
-- Each section loads independently — don't block SSL results waiting for DNS
-  or vice versa
-
-## Technical constraints
-- SwiftUI, iOS only
-- Fully offline except for DNS-over-HTTPS requests and SSL connections
-- No accounts, no analytics, no ads
-- No third-party dependencies — URLSession and Network framework only
-- Targets latest iOS
+- Run button to trigger all lookups simultaneously
+- Results displayed in clearly separated sections:
+  Reachability → Redirect Chain → DNS Records (with PTR inline) →
+  Email Security → SSL/TLS Certificate → HTTP Headers → IP Location →
+  Open Ports
+- Each section loads independently — don't block one section waiting for another
+- Email security, PTR, and IP geolocation are chained after DNS; all others
+  run in parallel
 
 ### Recent searches
 Store the last 20 searched domains locally using UserDefaults. Display them as
@@ -65,15 +117,55 @@ a tappable list below the text field when no results are showing. Tapping a
 recent domain populates the text field and runs the lookup immediately. Include
 a "Clear" button to wipe history. Most recent at the top.
 
+### Saved domains
+Bookmark button (SF Symbol: `bookmark` / `bookmark.fill`) in the results
+toolbar area, next to the share button. Tapping saves/unsaves the current
+domain. Filled icon when saved. Saved domains viewable from a toolbar button
+that pushes to `SavedDomainsView` — a list of saved domains, tappable to run
+lookups, with swipe-to-delete and an Edit button for bulk deletion.
+Persisted in UserDefaults under key `savedDomains`.
+
+### Lookup history with cached results
+After each successful lookup, a snapshot of all results (DNS, SSL, HTTP headers,
+reachability, geolocation, email security, PTR, redirect chain, port scan) is
+saved to history in UserDefaults as JSON. Capped at 50 entries. `HistoryView`
+shows past lookups with domain and timestamp. Tapping shows full cached results
+in `HistoryDetailView` using the same layout, labelled as cached with the
+original timestamp. Model: `HistoryEntry` (Codable).
+
 ### Share / export
 A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the
-results area. Formats the full DNS and SSL results as plain text and presents
-the iOS share sheet via ShareLink or UIActivityViewController. The export
-should include the domain, timestamp, all DNS records with TTLs, and all SSL
-cert fields.
+results area. Formats the full results as plain text and presents the iOS share
+sheet via UIActivityViewController. The export includes: domain, timestamp,
+reachability, redirect chain, all DNS records with TTLs and PTR, email security
+records, SSL cert fields, HTTP headers, IP geolocation data, and port scan
+results.
+
+## Technical constraints
+- SwiftUI, iOS only
+- Fully offline except for network requests (DNS-over-HTTPS, SSL, HTTP HEAD,
+  TCP connections, geolocation API)
+- No accounts, no analytics, no ads
+- No third-party dependencies — URLSession, Network, MapKit only
+- Targets latest iOS
+
+## Architecture
+- `Models.swift` — All data models (DNS, SSL, HTTP headers, reachability,
+  geolocation, history entry), all Codable for persistence
+- `DomainViewModel.swift` — `@Observable` view model orchestrating all lookups,
+  managing state, history, saved domains, recent searches, and export
+- `ContentView.swift` — Main screen with input, all result sections, toolbar
+- `SavedDomainsView.swift` — Saved domains list with edit/delete
+- `HistoryView.swift` — History list + `HistoryDetailView` for cached results
+- Services: `DNSLookupService`, `SSLCheckService`, `HTTPHeadersService`,
+  `ReachabilityService`, `IPGeolocationService`, `EmailSecurityService`,
+  `ReverseDNSService`, `RedirectChainService`, `PortScanService`
 
 ## What good looks like
 A developer pastes a domain, taps run, and within a couple of seconds sees a
-clean breakdown of every DNS record type and the full SSL cert status. It should
-feel like a native, polished version of running `dig` and `openssl s_client`
-from the terminal.
+clean breakdown of TCP reachability, redirect chain, every DNS record type with
+reverse DNS, email security posture, the full SSL cert status, HTTP response
+headers with security headers highlighted, IP geolocation with a map, and a
+port scan of common services. It should feel like a native, polished version of
+running `dig`, `openssl s_client`, `curl -I`, `nmap`, and `whois` from the
+terminal.