summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDig/ContentView.swift4
-rw-r--r--DomainDig/DomainDigApp.swift4
-rw-r--r--DomainDig/DomainMonitoringService.swift91
-rw-r--r--DomainDig/IntegrationService.swift813
-rw-r--r--DomainDig/IntegrationsView.swift522
-rw-r--r--DomainDig/Models.swift377
7 files changed, 1815 insertions, 4 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index a6799c2..c641a56 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -366,7 +366,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 31;
+ CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -383,7 +383,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 4.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -403,7 +403,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 31;
+ CURRENT_PROJECT_VERSION = 32;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -420,7 +420,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 4.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 754909c..c9f40d6 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -2540,6 +2540,10 @@ struct SettingsView: View {
MonitoringView(viewModel: viewModel)
}
+ NavigationLink("Integrations") {
+ IntegrationsSettingsView()
+ }
+
NavigationLink("iCloud Sync") {
CloudSyncSettingsView()
}
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index 4bb1b8f..2071213 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -28,16 +28,19 @@ struct DomainDigApp: App {
.task {
let _ = purchaseService.currentTier
let _ = cloudSyncService.status
+ let _ = IntegrationService.shared.targets.count
await purchaseService.refreshEntitlements()
viewModel.refreshMonitoringState()
await viewModel.refreshMonitoringAuthorizationStatus()
await cloudSyncService.refreshAvailability()
cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+ IntegrationService.shared.processQueueNow()
}
.onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in
viewModel.refreshPersistedData()
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+ IntegrationService.shared.refresh()
}
}
.onChange(of: scenePhase) { _, newValue in
@@ -49,6 +52,7 @@ struct DomainDigApp: App {
}
cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
+ IntegrationService.shared.processQueueNow()
}
}
}
diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
index 5034402..31b1505 100644
--- a/DomainDig/DomainMonitoringService.swift
+++ b/DomainDig/DomainMonitoringService.swift
@@ -361,6 +361,12 @@ final class DomainMonitoringService {
errors: errors
)
saveLog(log)
+ let outboundEvents = monitoringEvents(from: log)
+ if outboundEvents.isEmpty {
+ IntegrationService.shared.recordNoOutboundEvents(for: log.summary)
+ } else {
+ IntegrationService.shared.enqueue(events: outboundEvents)
+ }
return MonitoringRunOutcome(
success: errors.count < results.count,
@@ -375,6 +381,91 @@ final class DomainMonitoringService {
MonitoringStorage.saveLogs(logs)
}
+ private func monitoringEvents(from log: MonitoringLog) -> [MonitoringEvent] {
+ var events: [MonitoringEvent] = log.checkedDomains.compactMap { result in
+ if let errorMessage = result.errorMessage {
+ return MonitoringEvent(
+ type: .monitoringFailure,
+ severity: .critical,
+ domain: result.domain,
+ timestamp: result.checkedAt,
+ summary: errorMessage,
+ details: [
+ "trigger": log.trigger.rawValue,
+ "resultSource": result.resultSource.rawValue
+ ]
+ )
+ }
+
+ if result.certificateWarningLevel == .critical {
+ return MonitoringEvent(
+ type: .certificateExpiring,
+ severity: .critical,
+ domain: result.domain,
+ timestamp: result.checkedAt,
+ summary: result.summaryMessage,
+ details: [
+ "certificateWarningLevel": result.certificateWarningLevel.rawValue,
+ "trigger": log.trigger.rawValue
+ ]
+ )
+ }
+
+ guard let alertSeverity = result.alertSeverity else {
+ return nil
+ }
+
+ return MonitoringEvent(
+ type: eventType(for: result.summaryMessage),
+ severity: EventSeverity(monitoringSeverity: alertSeverity),
+ domain: result.domain,
+ timestamp: result.checkedAt,
+ summary: result.summaryMessage,
+ details: [
+ "alertSeverity": alertSeverity.title,
+ "certificateWarningLevel": result.certificateWarningLevel.rawValue,
+ "resultSource": result.resultSource.rawValue,
+ "trigger": log.trigger.rawValue
+ ]
+ )
+ }
+
+ if !log.errors.isEmpty {
+ events.append(
+ MonitoringEvent(
+ type: .monitoringFailure,
+ severity: .critical,
+ domain: "portfolio",
+ timestamp: log.timestamp,
+ summary: "Monitoring run completed with errors",
+ details: [
+ "errors": log.errors.joined(separator: " | "),
+ "trigger": log.trigger.rawValue
+ ]
+ )
+ )
+ }
+
+ return events
+ }
+
+ private func eventType(for summary: String) -> MonitoringEventType {
+ let normalized = summary.lowercased()
+ if normalized.contains("dns") {
+ return .dnsChanged
+ }
+ if normalized.contains("certificate") {
+ return .certificateUpdated
+ }
+ if normalized.contains("redirect") {
+ return .redirectChanged
+ }
+ if normalized.contains("header") {
+ return .headersChanged
+ }
+ return .changeDetected
+ }
+
private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? {
history.first(where: { entry in
if let trackedDomainID = entry.trackedDomainID {
diff --git a/DomainDig/IntegrationService.swift b/DomainDig/IntegrationService.swift
new file mode 100644
index 0000000..838cfc2
--- /dev/null
+++ b/DomainDig/IntegrationService.swift
@@ -0,0 +1,813 @@
+import Foundation
+import Network
+import Observation
+import Security
+
+@MainActor
+@Observable
+final class IntegrationService {
+ static let shared = IntegrationService()
+
+ var targets: [IntegrationTarget]
+ var deliveryRecords: [DeliveryRecord]
+ var queue: [QueuedDelivery]
+ var statusMessage: String?
+
+ private let defaults: UserDefaults
+ private var processingTask: Task<Void, Never>?
+
+ private init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ self.targets = Self.loadTargets(defaults: defaults)
+ self.deliveryRecords = Self.loadRecords(defaults: defaults)
+ self.queue = Self.loadQueue(defaults: defaults)
+ }
+
+ func refresh() {
+ targets = Self.loadTargets(defaults: defaults)
+ deliveryRecords = Self.loadRecords(defaults: defaults)
+ queue = Self.loadQueue(defaults: defaults)
+ }
+
+ func upsert(
+ target: IntegrationTarget,
+ webhookURL: String? = nil,
+ slackWebhookURL: String? = nil,
+ emailPassword: String? = nil
+ ) throws {
+ var updatedTarget = target
+
+ switch updatedTarget.configuration {
+ case .webhook(var configuration):
+ if let webhookURL {
+ let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "webhook")
+ try IntegrationSecretStore.save(secret: webhookURL, reference: reference)
+ configuration.credentialReference = reference
+ configuration.endpointDisplayHost = Self.hostLabel(from: webhookURL)
+ updatedTarget.configuration = .webhook(configuration)
+ }
+ case .slack(var configuration):
+ if let slackWebhookURL {
+ let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "slack")
+ try IntegrationSecretStore.save(secret: slackWebhookURL, reference: reference)
+ configuration.credentialReference = reference
+ configuration.destinationLabel = Self.hostLabel(from: slackWebhookURL)
+ updatedTarget.configuration = .slack(configuration)
+ }
+ case .email(var configuration):
+ if let emailPassword {
+ let reference = configuration.credentialReference ?? Self.secretReference(for: updatedTarget.id, suffix: "smtp")
+ try IntegrationSecretStore.save(secret: emailPassword, reference: reference)
+ configuration.credentialReference = reference
+ updatedTarget.configuration = .email(configuration)
+ }
+ }
+
+ if let index = targets.firstIndex(where: { $0.id == updatedTarget.id }) {
+ targets[index] = updatedTarget
+ } else {
+ targets.append(updatedTarget)
+ }
+ targets.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ persistTargets()
+ statusMessage = "Saved integration settings."
+ }
+
+ func delete(targetID: UUID) {
+ guard let target = targets.first(where: { $0.id == targetID }) else { return }
+ deleteSecrets(for: target)
+ targets.removeAll { $0.id == targetID }
+ queue.removeAll { $0.integrationID == targetID }
+ deliveryRecords.removeAll { $0.integrationID == targetID }
+ persistTargets()
+ persistQueue()
+ persistRecords()
+ }
+
+ func setEnabled(_ isEnabled: Bool, for targetID: UUID) {
+ guard let index = targets.firstIndex(where: { $0.id == targetID }) else { return }
+ targets[index].isEnabled = isEnabled
+ persistTargets()
+ }
+
+ func deliveryRecords(for targetID: UUID) -> [DeliveryRecord] {
+ deliveryRecords
+ .filter { $0.integrationID == targetID }
+ .sorted { $0.timestamp > $1.timestamp }
+ }
+
+ func enqueue(events: [MonitoringEvent]) {
+ guard !events.isEmpty else { return }
+ let eligibleTargets = targets.filter(\.isEnabled)
+ for event in events {
+ for target in eligibleTargets {
+ if let reason = filterMismatchReason(for: event, target: target) {
+ appendRecord(
+ DeliveryRecord(
+ integrationID: target.id,
+ eventID: event.id,
+ status: .skipped,
+ destination: destinationLabel(for: target),
+ summary: event.summary,
+ failureReason: reason
+ )
+ )
+ continue
+ }
+
+ queue.append(QueuedDelivery(integrationID: target.id, event: event))
+ appendRecord(
+ DeliveryRecord(
+ integrationID: target.id,
+ eventID: event.id,
+ status: .pending,
+ destination: destinationLabel(for: target),
+ summary: event.summary
+ )
+ )
+ }
+ }
+ persistQueue()
+ scheduleProcessing()
+ }
+
+ func recordNoOutboundEvents(for runSummary: String) {
+ let eligibleTargets = targets.filter(\.isEnabled)
+ for target in eligibleTargets {
+ appendRecord(
+ DeliveryRecord(
+ integrationID: target.id,
+ eventID: UUID(),
+ status: .skipped,
+ destination: destinationLabel(for: target),
+ summary: runSummary,
+ failureReason: "Monitoring run produced no outbound events."
+ )
+ )
+ }
+ }
+
+ func sendTest(for targetID: UUID) {
+ guard targets.contains(where: { $0.id == targetID }) else { return }
+ let event = MonitoringEvent(
+ type: .test,
+ severity: .info,
+ domain: "example.com",
+ summary: "DomainDig integration test",
+ details: [
+ "source": "manual test",
+ "environment": "local-first"
+ ]
+ )
+ queue.append(QueuedDelivery(integrationID: targetID, event: event))
+ appendRecord(
+ DeliveryRecord(
+ integrationID: targetID,
+ eventID: event.id,
+ status: .pending,
+ destination: targets.first(where: { $0.id == targetID }).map(destinationLabel(for:)) ?? "Unknown",
+ summary: event.summary
+ )
+ )
+ persistQueue()
+ scheduleProcessing()
+ }
+
+ func processQueueNow() {
+ scheduleProcessing(force: true)
+ }
+
+ private func scheduleProcessing(force: Bool = false) {
+ if force {
+ processingTask?.cancel()
+ processingTask = nil
+ }
+ guard processingTask == nil else { return }
+ processingTask = Task { [weak self] in
+ guard let self else { return }
+ await self.processQueueLoop()
+ }
+ }
+
+ private func processQueueLoop() async {
+ defer { processingTask = nil }
+
+ while true {
+ let dueItems = queue
+ .enumerated()
+ .filter { $0.element.nextAttemptAt <= Date() }
+
+ if dueItems.isEmpty {
+ guard let nextAttemptAt = queue.map(\.nextAttemptAt).min() else {
+ break
+ }
+
+ let delay = max(0.25, nextAttemptAt.timeIntervalSinceNow)
+ do {
+ try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ continue
+ } catch {
+ break
+ }
+ }
+
+ for entry in dueItems.reversed() {
+ guard entry.offset < queue.count else { continue }
+ let item = queue[entry.offset]
+ await process(item: item, at: entry.offset)
+ }
+ }
+ }
+
+ private func process(item: QueuedDelivery, at index: Int) async {
+ guard let target = targets.first(where: { $0.id == item.integrationID }) else {
+ queue.remove(at: index)
+ persistQueue()
+ return
+ }
+
+ guard item.expiresAt > Date() else {
+ queue.remove(at: index)
+ persistQueue()
+ appendRecord(
+ DeliveryRecord(
+ integrationID: target.id,
+ eventID: item.event.id,
+ status: .expired,
+ destination: destinationLabel(for: target),
+ summary: item.event.summary,
+ failureReason: "Delivery expired before succeeding.",
+ attemptCount: item.attemptCount
+ )
+ )
+ return
+ }
+
+ do {
+ try await deliver(item.event, to: target)
+ queue.remove(at: index)
+ persistQueue()
+ appendRecord(
+ DeliveryRecord(
+ integrationID: target.id,
+ eventID: item.event.id,
+ status: .delivered,
+ destination: destinationLabel(for: target),
+ summary: item.event.summary,
+ attemptCount: item.attemptCount + 1
+ )
+ )
+ statusMessage = "Delivered \(item.event.summary)."
+ } catch {
+ var updated = item
+ updated.attemptCount += 1
+ updated.lastError = error.localizedDescription
+
+ if updated.attemptCount >= 5 {
+ queue.remove(at: index)
+ appendRecord(
+ DeliveryRecord(
+ integrationID: target.id,
+ eventID: item.event.id,
+ status: .failed,
+ destination: destinationLabel(for: target),
+ summary: item.event.summary,
+ failureReason: error.localizedDescription,
+ attemptCount: updated.attemptCount
+ )
+ )
+ } else {
+ let backoff = min(pow(2, Double(updated.attemptCount)) * 30, 3600)
+ updated.nextAttemptAt = Date().addingTimeInterval(backoff)
+ queue[index] = updated
+ appendRecord(
+ DeliveryRecord(
+ integrationID: target.id,
+ eventID: item.event.id,
+ status: .retrying,
+ destination: destinationLabel(for: target),
+ summary: item.event.summary,
+ failureReason: error.localizedDescription,
+ attemptCount: updated.attemptCount
+ )
+ )
+ }
+
+ persistQueue()
+ statusMessage = error.localizedDescription
+ }
+ }
+
+ private func deliver(_ event: MonitoringEvent, to target: IntegrationTarget) async throws {
+ switch target.configuration {
+ case .webhook(let configuration):
+ guard let reference = configuration.credentialReference else {
+ throw IntegrationError.missingSecret
+ }
+ let webhookURLString = try IntegrationSecretStore.secret(reference: reference)
+ try await HTTPIntegrationClient.sendJSON(
+ payload: IntegrationEventPayload(event: event),
+ to: webhookURLString,
+ headers: configuration.additionalHeaders,
+ timeoutSeconds: configuration.timeoutSeconds
+ )
+ case .slack(let configuration):
+ guard let reference = configuration.credentialReference else {
+ throw IntegrationError.missingSecret
+ }
+ let webhookURLString = try IntegrationSecretStore.secret(reference: reference)
+ try await HTTPIntegrationClient.sendJSON(
+ payload: SlackPayload(event: event),
+ to: webhookURLString,
+ headers: [:],
+ timeoutSeconds: 15
+ )
+ case .email(let configuration):
+ guard let reference = configuration.credentialReference else {
+ throw IntegrationError.missingSecret
+ }
+ let password = try IntegrationSecretStore.secret(reference: reference)
+ try await SMTPClient.send(
+ event: event,
+ configuration: configuration,
+ password: password
+ )
+ }
+ }
+
+ private func appendRecord(_ record: DeliveryRecord) {
+ deliveryRecords.insert(record, at: 0)
+ deliveryRecords = Array(deliveryRecords.prefix(250))
+ persistRecords()
+ }
+
+ private func persistTargets() {
+ Self.save(targets, key: StorageKey.targets, defaults: defaults)
+ }
+
+ private func persistRecords() {
+ Self.save(deliveryRecords, key: StorageKey.records, defaults: defaults)
+ }
+
+ private func persistQueue() {
+ Self.save(queue, key: StorageKey.queue, defaults: defaults)
+ }
+
+ private func deleteSecrets(for target: IntegrationTarget) {
+ switch target.configuration {
+ case .webhook(let configuration):
+ if let reference = configuration.credentialReference {
+ try? IntegrationSecretStore.delete(reference: reference)
+ }
+ case .slack(let configuration):
+ if let reference = configuration.credentialReference {
+ try? IntegrationSecretStore.delete(reference: reference)
+ }
+ case .email(let configuration):
+ if let reference = configuration.credentialReference {
+ try? IntegrationSecretStore.delete(reference: reference)
+ }
+ }
+ }
+
+ private func destinationLabel(for target: IntegrationTarget) -> String {
+ switch target.configuration {
+ case .webhook(let configuration):
+ return configuration.endpointDisplayHost.isEmpty ? target.name : configuration.endpointDisplayHost
+ case .slack(let configuration):
+ return configuration.destinationLabel
+ case .email(let configuration):
+ return configuration.recipientAddresses.joined(separator: ", ")
+ }
+ }
+
+ private func filterMismatchReason(for event: MonitoringEvent, target: IntegrationTarget) -> String? {
+ let filters = target.filters
+
+ if event.severity < filters.minimumSeverity {
+ return "Filtered by severity. Event was \(event.severity.title), target requires \(filters.minimumSeverity.title)."
+ }
+
+ if !filters.eventTypes.isEmpty, !filters.eventTypes.contains(event.type) {
+ return "Filtered by event type. Event was \(event.type.title)."
+ }
+
+ if !filters.domains.isEmpty, !filters.domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) {
+ return "Filtered by domain. Event was for \(event.domain)."
+ }
+
+ return nil
+ }
+
+ private static func hostLabel(from string: String) -> String {
+ URL(string: string)?.host ?? "Configured"
+ }
+
+ private static func secretReference(for integrationID: UUID, suffix: String) -> String {
+ "integration.\(integrationID.uuidString).\(suffix)"
+ }
+
+ private static func loadTargets(defaults: UserDefaults) -> [IntegrationTarget] {
+ load([IntegrationTarget].self, key: StorageKey.targets, defaults: defaults) ?? []
+ }
+
+ private static func loadRecords(defaults: UserDefaults) -> [DeliveryRecord] {
+ load([DeliveryRecord].self, key: StorageKey.records, defaults: defaults) ?? []
+ }
+
+ private static func loadQueue(defaults: UserDefaults) -> [QueuedDelivery] {
+ load([QueuedDelivery].self, key: StorageKey.queue, defaults: defaults) ?? []
+ }
+
+ private static func load<T: Decodable>(_ type: T.Type, key: String, defaults: UserDefaults) -> T? {
+ guard let data = defaults.data(forKey: key) else {
+ return nil
+ }
+ return try? JSONDecoder().decode(type, from: data)
+ }
+
+ private static func save<T: Encodable>(_ value: T, key: String, defaults: UserDefaults) {
+ if let data = try? JSONEncoder().encode(value) {
+ defaults.set(data, forKey: key)
+ }
+ }
+
+ private enum StorageKey {
+ static let targets = "integrations.targets"
+ static let records = "integrations.records"
+ static let queue = "integrations.queue"
+ }
+}
+
+private struct IntegrationEventPayload: Encodable {
+ let eventType: String
+ let domain: String
+ let timestamp: Date
+ let severity: String
+ let summary: String
+ let details: [String: String]
+
+ init(event: MonitoringEvent) {
+ self.eventType = event.type.rawValue
+ self.domain = event.domain
+ self.timestamp = event.timestamp
+ self.severity = event.severity.rawValue
+ self.summary = event.summary
+ self.details = event.details
+ }
+}
+
+private struct SlackPayload: Encodable {
+ let text: String
+ let blocks: [SlackBlock]
+
+ init(event: MonitoringEvent) {
+ let title = "\(event.severity.title.uppercased()) • \(event.domain)"
+ let detailLines = event.details
+ .sorted { $0.key < $1.key }
+ .prefix(6)
+ .map { "\($0.key): \($0.value)" }
+ .joined(separator: "\n")
+
+ self.text = "\(title) — \(event.summary)"
+ self.blocks = [
+ SlackBlock(
+ type: "section",
+ text: .init(type: "mrkdwn", text: "*\(title)*\n\(event.summary)")
+ ),
+ SlackBlock(
+ type: "section",
+ text: .init(
+ type: "mrkdwn",
+ text: "*Event*: \(event.type.title)\n*Timestamp*: \(event.timestamp.formatted(date: .abbreviated, time: .shortened))"
+ )
+ ),
+ SlackBlock(
+ type: "section",
+ text: .init(type: "mrkdwn", text: detailLines.isEmpty ? "_No extra details_" : detailLines)
+ )
+ ]
+ }
+}
+
+private struct SlackBlock: Encodable {
+ let type: String
+ let text: SlackText
+}
+
+private struct SlackText: Encodable {
+ let type: String
+ let text: String
+}
+
+private enum IntegrationError: LocalizedError {
+ case invalidURL
+ case missingSecret
+ case invalidResponse(Int)
+ case invalidSMTPPort
+ case smtp(String)
+ case streamClosed
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL:
+ return "The integration URL is invalid."
+ case .missingSecret:
+ return "This integration is missing a saved secret."
+ case .invalidResponse(let statusCode):
+ return "The remote endpoint returned \(statusCode)."
+ case .invalidSMTPPort:
+ return "The SMTP port is invalid."
+ case .smtp(let message):
+ return message
+ case .streamClosed:
+ return "The SMTP connection closed unexpectedly."
+ }
+ }
+}
+
+private enum HTTPIntegrationClient {
+ static func sendJSON<T: Encodable>(
+ payload: T,
+ to urlString: String,
+ headers: [String: String],
+ timeoutSeconds: Double
+ ) async throws {
+ guard let url = URL(string: urlString) else {
+ throw IntegrationError.invalidURL
+ }
+
+ var request = URLRequest(url: url, timeoutInterval: timeoutSeconds)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ for (key, value) in headers {
+ request.setValue(value, forHTTPHeaderField: key)
+ }
+
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ request.httpBody = try encoder.encode(payload)
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw IntegrationError.invalidResponse(-1)
+ }
+ guard (200..<300).contains(httpResponse.statusCode) else {
+ throw IntegrationError.invalidResponse(httpResponse.statusCode)
+ }
+ }
+}
+
+private enum IntegrationSecretStore {
+ static func save(secret: String, reference: String) throws {
+ let data = Data(secret.utf8)
+ try? delete(reference: reference)
+
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: reference,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
+ ]
+
+ let status = SecItemAdd(query as CFDictionary, nil)
+ guard status == errSecSuccess else {
+ throw IntegrationError.smtp("Could not save integration secret.")
+ }
+ }
+
+ static func secret(reference: String) throws -> String {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: reference,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let secret = String(data: data, encoding: .utf8) else {
+ throw IntegrationError.missingSecret
+ }
+
+ return secret
+ }
+
+ static func delete(reference: String) throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: reference
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
+
+private enum SMTPClient {
+ static func send(
+ event: MonitoringEvent,
+ configuration: EmailIntegrationConfiguration,
+ password: String
+ ) async throws {
+ guard let port = NWEndpoint.Port(rawValue: UInt16(configuration.port)) else {
+ throw IntegrationError.invalidSMTPPort
+ }
+
+ let parameters: NWParameters = {
+ switch configuration.securityMode {
+ case .plain:
+ return .tcp
+ case .directTLS:
+ let tls = NWProtocolTLS.Options()
+ return NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
+ }
+ }()
+
+ let channel = SMTPChannel(host: configuration.smtpHost, port: port, parameters: parameters)
+ try await channel.start()
+ _ = try await channel.readResponse(expecting: [220])
+ _ = try await channel.sendCommand("EHLO domaindig.local", expecting: [250])
+
+ if !configuration.username.isEmpty {
+ _ = try await channel.sendCommand("AUTH LOGIN", expecting: [334])
+ _ = try await channel.sendCommand(Data(configuration.username.utf8).base64EncodedString(), expecting: [334])
+ _ = try await channel.sendCommand(Data(password.utf8).base64EncodedString(), expecting: [235])
+ }
+
+ _ = try await channel.sendCommand("MAIL FROM:<\(configuration.senderAddress)>", expecting: [250])
+ for recipient in configuration.recipientAddresses {
+ _ = try await channel.sendCommand("RCPT TO:<\(recipient)>", expecting: [250, 251])
+ }
+ _ = try await channel.sendCommand("DATA", expecting: [354])
+
+ let detailLines = event.details
+ .sorted { $0.key < $1.key }
+ .map { "\($0.key): \($0.value)" }
+ .joined(separator: "\r\n")
+ let body = [
+ "From: DomainDig <\(configuration.senderAddress)>",
+ "To: \(configuration.recipientAddresses.joined(separator: ", "))",
+ "Subject: [DomainDig] \(event.severity.title) \(event.domain) \(event.type.title)",
+ "Date: \(DateFormatter.rfc2822.string(from: Date()))",
+ "",
+ event.summary,
+ "",
+ "Domain: \(event.domain)",
+ "Severity: \(event.severity.title)",
+ "Event: \(event.type.title)",
+ "Timestamp: \(event.timestamp.formatted(date: .abbreviated, time: .shortened))",
+ detailLines
+ ]
+ .joined(separator: "\r\n")
+
+ try await channel.sendRaw(body + "\r\n.\r\n")
+ _ = try await channel.readResponse(expecting: [250])
+ _ = try await channel.sendCommand("QUIT", expecting: [221])
+ channel.cancel()
+ }
+}
+
+private final class SMTPChannel {
+ private let connection: NWConnection
+ private var parsedLines: [String] = []
+ private var lineWaiters: [CheckedContinuation<String, Error>] = []
+ private var receiveBuffer = Data()
+
+ init(host: String, port: NWEndpoint.Port, parameters: NWParameters) {
+ connection = NWConnection(host: NWEndpoint.Host(host), port: port, using: parameters)
+ }
+
+ func start() async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+ connection.stateUpdateHandler = { [weak self] state in
+ switch state {
+ case .ready:
+ DispatchQueue.global(qos: .utility).async {
+ self?.startReceiveLoop()
+ }
+ continuation.resume()
+ case .failed(let error):
+ continuation.resume(throwing: error)
+ default:
+ break
+ }
+ }
+ connection.start(queue: .global(qos: .utility))
+ }
+ }
+
+ func cancel() {
+ connection.cancel()
+ }
+
+ func sendCommand(_ command: String, expecting codes: Set<Int>) async throws -> String {
+ try await sendRaw(command + "\r\n")
+ return try await readResponse(expecting: codes)
+ }
+
+ func sendCommand(_ command: String, expecting codes: [Int]) async throws -> String {
+ try await sendCommand(command, expecting: Set(codes))
+ }
+
+ func sendRaw(_ string: String) async throws {
+ let data = Data(string.utf8)
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+ connection.send(content: data, completion: .contentProcessed { error in
+ if let error {
+ continuation.resume(throwing: error)
+ } else {
+ continuation.resume()
+ }
+ })
+ }
+ }
+
+ func readResponse(expecting codes: Set<Int>) async throws -> String {
+ var lines: [String] = []
+
+ while true {
+ let line = try await readLine()
+ lines.append(line)
+
+ guard line.count >= 4,
+ let code = Int(line.prefix(3)) else {
+ continue
+ }
+
+ let delimiterIndex = line.index(line.startIndex, offsetBy: 3)
+ if line[delimiterIndex] == " " {
+ guard codes.contains(code) else {
+ throw IntegrationError.smtp(line)
+ }
+ return lines.joined(separator: "\n")
+ }
+ }
+ }
+
+ private func readLine() async throws -> String {
+ if !parsedLines.isEmpty {
+ return parsedLines.removeFirst()
+ }
+
+ return try await withCheckedThrowingContinuation { continuation in
+ lineWaiters.append(continuation)
+ }
+ }
+
+ private func startReceiveLoop() {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
+ guard let self else { return }
+
+ if let error {
+ self.failWaiters(with: error)
+ return
+ }
+
+ if let data, !data.isEmpty {
+ self.receiveBuffer.append(data)
+ self.flushBuffer()
+ }
+
+ if isComplete {
+ self.failWaiters(with: IntegrationError.streamClosed)
+ return
+ }
+
+ self.startReceiveLoop()
+ }
+ }
+
+ private func flushBuffer() {
+ let delimiter = Data("\r\n".utf8)
+ while let range = receiveBuffer.range(of: delimiter) {
+ let lineData = receiveBuffer.subdata(in: receiveBuffer.startIndex..<range.lowerBound)
+ receiveBuffer.removeSubrange(receiveBuffer.startIndex..<range.upperBound)
+ let line = String(data: lineData, encoding: .utf8) ?? ""
+ if !lineWaiters.isEmpty {
+ let continuation = lineWaiters.removeFirst()
+ continuation.resume(returning: line)
+ } else {
+ parsedLines.append(line)
+ }
+ }
+ }
+
+ private func failWaiters(with error: Error) {
+ let waiters = lineWaiters
+ lineWaiters.removeAll()
+ for waiter in waiters {
+ waiter.resume(throwing: error)
+ }
+ }
+}
+
+private extension DateFormatter {
+ static let rfc2822: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
+ return formatter
+ }()
+}
diff --git a/DomainDig/IntegrationsView.swift b/DomainDig/IntegrationsView.swift
new file mode 100644
index 0000000..73f511a
--- /dev/null
+++ b/DomainDig/IntegrationsView.swift
@@ -0,0 +1,522 @@
+import SwiftUI
+
+struct IntegrationsSettingsView: View {
+ @State private var integrationService = IntegrationService.shared
+ @State private var editingTarget: IntegrationTarget?
+ @State private var showingCreateSheet = false
+
+ var body: some View {
+ List {
+ Section("Overview") {
+ LabeledContent("Integrations", value: "\(integrationService.targets.count)")
+ LabeledContent("Queued Deliveries", value: "\(integrationService.queue.count)")
+ LabeledContent("Recent Log Entries", value: "\(integrationService.deliveryRecords.count)")
+
+ if let statusMessage = integrationService.statusMessage {
+ Text(statusMessage)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Button("Process Queue Now") {
+ integrationService.processQueueNow()
+ }
+ }
+
+ Section("Targets") {
+ if integrationService.targets.isEmpty {
+ Text("No integrations configured.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(integrationService.targets) { target in
+ NavigationLink {
+ IntegrationDetailView(
+ integrationID: target.id,
+ onEdit: {
+ editingTarget = target
+ }
+ )
+ } label: {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(target.name)
+ Spacer()
+ Text(target.type.title)
+ .foregroundStyle(.secondary)
+ }
+
+ Text(summary(for: target))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if !target.isEnabled {
+ Text("Disabled")
+ .font(.caption2)
+ .foregroundStyle(.orange)
+ }
+ }
+ }
+ }
+ }
+
+ Button("Add Integration") {
+ showingCreateSheet = true
+ }
+ }
+ }
+ .navigationTitle("Integrations")
+ .sheet(isPresented: $showingCreateSheet) {
+ NavigationStack {
+ IntegrationEditorView(existingTarget: nil)
+ }
+ }
+ .sheet(item: $editingTarget) { target in
+ NavigationStack {
+ IntegrationEditorView(existingTarget: target)
+ }
+ }
+ .onAppear {
+ integrationService.refresh()
+ }
+ }
+
+ private func summary(for target: IntegrationTarget) -> String {
+ switch target.configuration {
+ case .webhook(let configuration):
+ return configuration.endpointDisplayHost.isEmpty ? "Webhook" : configuration.endpointDisplayHost
+ case .slack(let configuration):
+ return configuration.destinationLabel
+ case .email(let configuration):
+ return configuration.recipientAddresses.joined(separator: ", ")
+ }
+ }
+}
+
+private struct IntegrationDetailView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var integrationService = IntegrationService.shared
+
+ let integrationID: UUID
+ let onEdit: () -> Void
+
+ private var target: IntegrationTarget? {
+ integrationService.targets.first(where: { $0.id == integrationID })
+ }
+
+ var body: some View {
+ List {
+ if let target {
+ Section("Configuration") {
+ LabeledContent("Type", value: target.type.title)
+ LabeledContent("Status", value: target.isEnabled ? "Enabled" : "Disabled")
+ LabeledContent("Destination", value: destination(for: target))
+ LabeledContent("Minimum Severity", value: target.filters.minimumSeverity.title)
+ if !target.filters.domains.isEmpty {
+ LabeledContent("Domains", value: target.filters.domains.joined(separator: ", "))
+ }
+ }
+
+ Section("Actions") {
+ Button("Edit Integration") {
+ onEdit()
+ }
+
+ Button("Send Test Event") {
+ integrationService.sendTest(for: target.id)
+ }
+
+ Button(target.isEnabled ? "Disable" : "Enable") {
+ integrationService.setEnabled(!target.isEnabled, for: target.id)
+ }
+
+ Button("Delete Integration", role: .destructive) {
+ integrationService.delete(targetID: target.id)
+ dismiss()
+ }
+ }
+
+ Section("Delivery Log") {
+ if integrationService.deliveryRecords(for: target.id).isEmpty {
+ Text("No deliveries yet.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(integrationService.deliveryRecords(for: target.id), id: \.id) { record in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(record.status.title)
+ Spacer()
+ Text(record.timestamp.formatted(date: .abbreviated, time: .shortened))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text(record.summary)
+ .font(.subheadline)
+
+ Text(record.destination)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if let failureReason = record.failureReason {
+ let failureColor: Color = record.status == .skipped ? .secondary : .red
+ Text(failureReason)
+ .font(.caption)
+ .foregroundStyle(failureColor)
+ }
+ }
+ }
+ }
+ }
+ } else {
+ Text("Integration not found.")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle(target?.name ?? "Integration")
+ }
+
+ private func destination(for target: IntegrationTarget) -> String {
+ switch target.configuration {
+ case .webhook(let configuration):
+ return configuration.endpointDisplayHost
+ case .slack(let configuration):
+ return configuration.destinationLabel
+ case .email(let configuration):
+ return configuration.recipientAddresses.joined(separator: ", ")
+ }
+ }
+}
+
+private struct IntegrationEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var integrationService = IntegrationService.shared
+
+ let existingTarget: IntegrationTarget?
+
+ @State private var type: IntegrationType = .webhook
+ @State private var name: String = ""
+ @State private var isEnabled = true
+ @State private var minimumSeverity: EventSeverity = .warning
+ @State private var selectedEventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test })
+ @State private var domainsText = ""
+
+ @State private var webhookURL = ""
+ @State private var slackWebhookURL = ""
+ @State private var emailHost = ""
+ @State private var emailPort = "465"
+ @State private var emailUsername = ""
+ @State private var emailPassword = ""
+ @State private var senderAddress = ""
+ @State private var recipientAddresses = ""
+ @State private var smtpSecurity: SMTPSecurityMode = .directTLS
+
+ @State private var validationMessage: String?
+
+ var body: some View {
+ Form {
+ Section("Integration") {
+ Picker("Type", selection: $type) {
+ ForEach(IntegrationType.allCases) { integrationType in
+ Text(integrationType.title).tag(integrationType)
+ }
+ }
+ .disabled(existingTarget != nil)
+
+ TextField("Name", text: $name)
+ Toggle("Enabled", isOn: $isEnabled)
+ }
+
+ Section("Routing Rules") {
+ Picker("Minimum Severity", selection: $minimumSeverity) {
+ ForEach(EventSeverity.allCases) { severity in
+ Text(severity.title).tag(severity)
+ }
+ }
+
+ TextField("Domains (comma-separated)", text: $domainsText)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ ForEach(MonitoringEventType.allCases.filter { $0 != .test }, id: \.self) { eventType in
+ Toggle(
+ eventType.title,
+ isOn: Binding(
+ get: { selectedEventTypes.contains(eventType) },
+ set: { isSelected in
+ if isSelected {
+ selectedEventTypes.insert(eventType)
+ } else {
+ selectedEventTypes.remove(eventType)
+ }
+ }
+ )
+ )
+ }
+ }
+
+ switch type {
+ case .webhook:
+ Section("Webhook") {
+ TextField("https://example.com/webhook", text: $webhookURL)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+
+ if existingTarget != nil {
+ Text("Saved webhook URL remains in Keychain unless you replace it.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ case .slack:
+ Section("Slack") {
+ TextField("https://hooks.slack.com/services/...", text: $slackWebhookURL)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+
+ if existingTarget != nil {
+ Text("Saved Slack webhook remains in Keychain unless you replace it.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ case .email:
+ Section("SMTP") {
+ TextField("SMTP Host", text: $emailHost)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ TextField("Port", text: $emailPort)
+ .keyboardType(.numberPad)
+
+ TextField("Username", text: $emailUsername)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ SecureField(existingTarget == nil ? "Password" : "Replace Password", text: $emailPassword)
+
+ TextField("Sender Address", text: $senderAddress)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.emailAddress)
+
+ TextField("Recipients (comma-separated)", text: $recipientAddresses)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.emailAddress)
+
+ Picker("Security", selection: $smtpSecurity) {
+ ForEach(SMTPSecurityMode.allCases) { mode in
+ Text(mode.title).tag(mode)
+ }
+ }
+
+ if existingTarget != nil {
+ Text("Saved SMTP password remains in Keychain unless you replace it.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ if let validationMessage {
+ Section {
+ Text(validationMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle(existingTarget == nil ? "Add Integration" : "Edit Integration")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ save()
+ }
+ }
+ }
+ .onAppear {
+ populateFromExisting()
+ }
+ }
+
+ private func populateFromExisting() {
+ guard let existingTarget else { return }
+ type = existingTarget.type
+ name = existingTarget.name
+ isEnabled = existingTarget.isEnabled
+ minimumSeverity = existingTarget.filters.minimumSeverity
+ selectedEventTypes = existingTarget.filters.eventTypes
+ domainsText = existingTarget.filters.domains.joined(separator: ", ")
+
+ switch existingTarget.configuration {
+ case .webhook:
+ break
+ case .slack:
+ break
+ case .email(let configuration):
+ emailHost = configuration.smtpHost
+ emailPort = String(configuration.port)
+ emailUsername = configuration.username
+ senderAddress = configuration.senderAddress
+ recipientAddresses = configuration.recipientAddresses.joined(separator: ", ")
+ smtpSecurity = configuration.securityMode
+ }
+ }
+
+ private func save() {
+ validationMessage = nil
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else {
+ validationMessage = "Name is required."
+ return
+ }
+
+ let filters = IntegrationFilterSet(
+ minimumSeverity: minimumSeverity,
+ eventTypes: selectedEventTypes,
+ domains: domainsText
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .filter { !$0.isEmpty }
+ )
+
+ let targetID = existingTarget?.id ?? UUID()
+
+ do {
+ switch type {
+ case .webhook:
+ let existingReference: String? = {
+ guard case .webhook(let configuration) = existingTarget?.configuration else { return nil }
+ return configuration.credentialReference
+ }()
+ if webhookURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
+ validationMessage = "Webhook URL is required."
+ return
+ }
+
+ let target = IntegrationTarget(
+ id: targetID,
+ type: .webhook,
+ name: trimmedName,
+ isEnabled: isEnabled,
+ configuration: .webhook(
+ WebhookIntegrationConfiguration(
+ endpointDisplayHost: existingWebhookDisplayHost(),
+ timeoutSeconds: 15,
+ additionalHeaders: [:],
+ credentialReference: existingReference
+ )
+ ),
+ filters: filters
+ )
+ try integrationService.upsert(
+ target: target,
+ webhookURL: webhookURL.nilIfBlank
+ )
+ case .slack:
+ let existingReference: String? = {
+ guard case .slack(let configuration) = existingTarget?.configuration else { return nil }
+ return configuration.credentialReference
+ }()
+ if slackWebhookURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
+ validationMessage = "Slack webhook URL is required."
+ return
+ }
+
+ let target = IntegrationTarget(
+ id: targetID,
+ type: .slack,
+ name: trimmedName,
+ isEnabled: isEnabled,
+ configuration: .slack(
+ SlackIntegrationConfiguration(
+ destinationLabel: existingSlackDestination(),
+ credentialReference: existingReference
+ )
+ ),
+ filters: filters
+ )
+ try integrationService.upsert(
+ target: target,
+ slackWebhookURL: slackWebhookURL.nilIfBlank
+ )
+ case .email:
+ guard let port = Int(emailPort) else {
+ validationMessage = "SMTP port must be a number."
+ return
+ }
+
+ let recipients = recipientAddresses
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+
+ let existingReference: String? = {
+ guard case .email(let configuration) = existingTarget?.configuration else { return nil }
+ return configuration.credentialReference
+ }()
+ if emailPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
+ validationMessage = "SMTP password is required."
+ return
+ }
+
+ let target = IntegrationTarget(
+ id: targetID,
+ type: .email,
+ name: trimmedName,
+ isEnabled: isEnabled,
+ configuration: .email(
+ EmailIntegrationConfiguration(
+ smtpHost: emailHost.trimmingCharacters(in: .whitespacesAndNewlines),
+ port: port,
+ username: emailUsername.trimmingCharacters(in: .whitespacesAndNewlines),
+ senderAddress: senderAddress.trimmingCharacters(in: .whitespacesAndNewlines),
+ recipientAddresses: recipients,
+ securityMode: smtpSecurity,
+ credentialReference: existingReference
+ )
+ ),
+ filters: filters
+ )
+ try integrationService.upsert(
+ target: target,
+ emailPassword: emailPassword.nilIfBlank
+ )
+ }
+
+ dismiss()
+ } catch {
+ validationMessage = error.localizedDescription
+ }
+ }
+
+ private func existingWebhookDisplayHost() -> String {
+ guard case .webhook(let configuration) = existingTarget?.configuration else {
+ return ""
+ }
+ return configuration.endpointDisplayHost
+ }
+
+ private func existingSlackDestination() -> String {
+ guard case .slack(let configuration) = existingTarget?.configuration else {
+ return "Slack"
+ }
+ return configuration.destinationLabel
+ }
+}
+
+private extension String {
+ var nilIfBlank: String? {
+ let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+}
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 54bea10..8e1d494 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -1336,6 +1336,383 @@ struct MonitoringLog: Codable, Identifiable, Equatable {
}
}
+enum EventSeverity: String, Codable, CaseIterable, Comparable, Identifiable, Sendable {
+ case info
+ case warning
+ case critical
+
+ var id: String { rawValue }
+
+ static func < (lhs: EventSeverity, rhs: EventSeverity) -> Bool {
+ lhs.rank < rhs.rank
+ }
+
+ var title: String {
+ rawValue.capitalized
+ }
+
+ private var rank: Int {
+ switch self {
+ case .info:
+ return 0
+ case .warning:
+ return 1
+ case .critical:
+ return 2
+ }
+ }
+
+ init(monitoringSeverity: MonitoringAlertSeverity) {
+ switch monitoringSeverity {
+ case .info:
+ self = .info
+ case .warning:
+ self = .warning
+ case .critical:
+ self = .critical
+ }
+ }
+}
+
+enum MonitoringEventType: String, Codable, CaseIterable, Identifiable, Sendable {
+ case dnsChanged
+ case certificateUpdated
+ case certificateExpiring
+ case redirectChanged
+ case headersChanged
+ case endpointUnreachable
+ case monitoringFailure
+ case changeDetected
+ case test
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .dnsChanged:
+ return "DNS Changed"
+ case .certificateUpdated:
+ return "Certificate Updated"
+ case .certificateExpiring:
+ return "Certificate Expiring"
+ case .redirectChanged:
+ return "Redirect Changed"
+ case .headersChanged:
+ return "Headers Changed"
+ case .endpointUnreachable:
+ return "Endpoint Unreachable"
+ case .monitoringFailure:
+ return "Monitoring Failure"
+ case .changeDetected:
+ return "Change Detected"
+ case .test:
+ return "Test Event"
+ }
+ }
+}
+
+struct MonitoringEvent: Codable, Identifiable, Equatable, Sendable {
+ var id: UUID
+ var type: MonitoringEventType
+ var severity: EventSeverity
+ var domain: String
+ var timestamp: Date
+ var summary: String
+ var details: [String: String]
+
+ init(
+ id: UUID = UUID(),
+ type: MonitoringEventType,
+ severity: EventSeverity,
+ domain: String,
+ timestamp: Date = Date(),
+ summary: String,
+ details: [String: String] = [:]
+ ) {
+ self.id = id
+ self.type = type
+ self.severity = severity
+ self.domain = domain
+ self.timestamp = timestamp
+ self.summary = summary
+ self.details = details
+ }
+}
+
+enum IntegrationType: String, Codable, CaseIterable, Identifiable, Sendable {
+ case webhook
+ case slack
+ case email
+
+ var id: String { rawValue }
+
+ var title: String {
+ rawValue.capitalized
+ }
+}
+
+enum SMTPSecurityMode: String, Codable, CaseIterable, Identifiable, Sendable {
+ case plain
+ case directTLS
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .plain:
+ return "Plain"
+ case .directTLS:
+ return "Direct TLS"
+ }
+ }
+}
+
+struct WebhookIntegrationConfiguration: Codable, Equatable, Sendable {
+ var endpointDisplayHost: String
+ var timeoutSeconds: Double
+ var additionalHeaders: [String: String]
+ var credentialReference: String?
+
+ init(
+ endpointDisplayHost: String = "",
+ timeoutSeconds: Double = 15,
+ additionalHeaders: [String: String] = [:],
+ credentialReference: String? = nil
+ ) {
+ self.endpointDisplayHost = endpointDisplayHost
+ self.timeoutSeconds = timeoutSeconds
+ self.additionalHeaders = additionalHeaders
+ self.credentialReference = credentialReference
+ }
+}
+
+struct SlackIntegrationConfiguration: Codable, Equatable, Sendable {
+ var destinationLabel: String
+ var credentialReference: String?
+
+ init(
+ destinationLabel: String = "Slack",
+ credentialReference: String? = nil
+ ) {
+ self.destinationLabel = destinationLabel
+ self.credentialReference = credentialReference
+ }
+}
+
+struct EmailIntegrationConfiguration: Codable, Equatable, Sendable {
+ var smtpHost: String
+ var port: Int
+ var username: String
+ var senderAddress: String
+ var recipientAddresses: [String]
+ var securityMode: SMTPSecurityMode
+ var credentialReference: String?
+
+ init(
+ smtpHost: String = "",
+ port: Int = 465,
+ username: String = "",
+ senderAddress: String = "",
+ recipientAddresses: [String] = [],
+ securityMode: SMTPSecurityMode = .directTLS,
+ credentialReference: String? = nil
+ ) {
+ self.smtpHost = smtpHost
+ self.port = port
+ self.username = username
+ self.senderAddress = senderAddress
+ self.recipientAddresses = recipientAddresses
+ self.securityMode = securityMode
+ self.credentialReference = credentialReference
+ }
+}
+
+enum IntegrationConfiguration: Codable, Equatable, Sendable {
+ case webhook(WebhookIntegrationConfiguration)
+ case slack(SlackIntegrationConfiguration)
+ case email(EmailIntegrationConfiguration)
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case webhook
+ case slack
+ case email
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(IntegrationType.self, forKey: .type)
+ switch type {
+ case .webhook:
+ self = .webhook(try container.decode(WebhookIntegrationConfiguration.self, forKey: .webhook))
+ case .slack:
+ self = .slack(try container.decode(SlackIntegrationConfiguration.self, forKey: .slack))
+ case .email:
+ self = .email(try container.decode(EmailIntegrationConfiguration.self, forKey: .email))
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .webhook(let configuration):
+ try container.encode(IntegrationType.webhook, forKey: .type)
+ try container.encode(configuration, forKey: .webhook)
+ case .slack(let configuration):
+ try container.encode(IntegrationType.slack, forKey: .type)
+ try container.encode(configuration, forKey: .slack)
+ case .email(let configuration):
+ try container.encode(IntegrationType.email, forKey: .type)
+ try container.encode(configuration, forKey: .email)
+ }
+ }
+
+ var type: IntegrationType {
+ switch self {
+ case .webhook:
+ return .webhook
+ case .slack:
+ return .slack
+ case .email:
+ return .email
+ }
+ }
+}
+
+struct IntegrationFilterSet: Codable, Equatable, Sendable {
+ var minimumSeverity: EventSeverity
+ var eventTypes: Set<MonitoringEventType>
+ var domains: [String]
+
+ init(
+ minimumSeverity: EventSeverity = .warning,
+ eventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test }),
+ domains: [String] = []
+ ) {
+ self.minimumSeverity = minimumSeverity
+ self.eventTypes = eventTypes
+ self.domains = domains
+ }
+
+ func matches(_ event: MonitoringEvent) -> Bool {
+ guard event.severity >= minimumSeverity else {
+ return false
+ }
+ guard eventTypes.isEmpty || eventTypes.contains(event.type) else {
+ return false
+ }
+ guard domains.isEmpty || domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) else {
+ return false
+ }
+ return true
+ }
+}
+
+struct IntegrationTarget: Codable, Identifiable, Equatable, Sendable {
+ var id: UUID
+ var type: IntegrationType
+ var name: String
+ var isEnabled: Bool
+ var configuration: IntegrationConfiguration
+ var filters: IntegrationFilterSet
+
+ init(
+ id: UUID = UUID(),
+ type: IntegrationType,
+ name: String,
+ isEnabled: Bool = true,
+ configuration: IntegrationConfiguration,
+ filters: IntegrationFilterSet = IntegrationFilterSet()
+ ) {
+ self.id = id
+ self.type = type
+ self.name = name
+ self.isEnabled = isEnabled
+ self.configuration = configuration
+ self.filters = filters
+ }
+}
+
+enum DeliveryStatus: String, Codable, CaseIterable, Identifiable, Sendable {
+ case pending
+ case retrying
+ case delivered
+ case failed
+ case expired
+ case skipped
+
+ var id: String { rawValue }
+
+ var title: String {
+ rawValue.capitalized
+ }
+}
+
+struct DeliveryRecord: Codable, Identifiable, Equatable, Sendable {
+ var id: UUID
+ var integrationID: UUID
+ var eventID: UUID
+ var timestamp: Date
+ var status: DeliveryStatus
+ var destination: String
+ var summary: String
+ var failureReason: String?
+ var attemptCount: Int
+
+ init(
+ id: UUID = UUID(),
+ integrationID: UUID,
+ eventID: UUID,
+ timestamp: Date = Date(),
+ status: DeliveryStatus,
+ destination: String,
+ summary: String,
+ failureReason: String? = nil,
+ attemptCount: Int = 0
+ ) {
+ self.id = id
+ self.integrationID = integrationID
+ self.eventID = eventID
+ self.timestamp = timestamp
+ self.status = status
+ self.destination = destination
+ self.summary = summary
+ self.failureReason = failureReason
+ self.attemptCount = attemptCount
+ }
+}
+
+struct QueuedDelivery: Codable, Identifiable, Equatable, Sendable {
+ var id: UUID
+ var integrationID: UUID
+ var event: MonitoringEvent
+ var createdAt: Date
+ var attemptCount: Int
+ var nextAttemptAt: Date
+ var lastError: String?
+ var expiresAt: Date
+
+ init(
+ id: UUID = UUID(),
+ integrationID: UUID,
+ event: MonitoringEvent,
+ createdAt: Date = Date(),
+ attemptCount: Int = 0,
+ nextAttemptAt: Date = Date(),
+ lastError: String? = nil,
+ expiresAt: Date = Date().addingTimeInterval(3 * 24 * 60 * 60)
+ ) {
+ self.id = id
+ self.integrationID = integrationID
+ self.event = event
+ self.createdAt = createdAt
+ self.attemptCount = attemptCount
+ self.nextAttemptAt = nextAttemptAt
+ self.lastError = lastError
+ self.expiresAt = expiresAt
+ }
+}
+
// MARK: - DNS Models
enum DNSRecordType: String, CaseIterable, Codable {