krz/domain-dig

an ios app for DNS & SSL analysis

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

1715e59287a7b48b29ed8cbf2cd45fcc92ec3126

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-25T05:25:18Z

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
 DomainDig.xcodeproj/project.pbxproj     |   8 +-
 DomainDig/ContentView.swift             |   4 +
 DomainDig/DomainDigApp.swift            |   4 +
 DomainDig/DomainMonitoringService.swift |  91 ++++
 DomainDig/IntegrationService.swift      | 813 ++++++++++++++++++++++++++++++++
 DomainDig/IntegrationsView.swift        | 522 ++++++++++++++++++++
 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 {