diff options
| author | Christian Cleberg <[email protected]> | 2026-04-25 00:25:18 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-25 00:25:18 -0500 |
| commit | 1715e59287a7b48b29ed8cbf2cd45fcc92ec3126 (patch) | |
| tree | cdca41731ecf7eff1aa83b20dc2a7c5fd33e649f | |
| parent | fd34553ab0e63b4d3bc22532b61f4fef32846f34 (diff) | |
| download | domain-dig-1715e59287a7b48b29ed8cbf2cd45fcc92ec3126.tar.gz domain-dig-1715e59287a7b48b29ed8cbf2cd45fcc92ec3126.tar.bz2 domain-dig-1715e59287a7b48b29ed8cbf2cd45fcc92ec3126.zip | |
DomainDig v4.0.0 — Integrations
* Outbound webhook delivery
* Native Slack webhook integration
* SMTP-based email alerts
* Per-integration event filtering
* Reliable local delivery queue
* Delivery logs and retry visibility
* Integration management UI
* Normalized event severity model
* Canonical structured event payloads
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 8 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 4 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 4 | ||||
| -rw-r--r-- | DomainDig/DomainMonitoringService.swift | 91 | ||||
| -rw-r--r-- | DomainDig/IntegrationService.swift | 813 | ||||
| -rw-r--r-- | DomainDig/IntegrationsView.swift | 522 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 377 |
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 { |
