Files
oai-swift/oAI/Views/Screens/EmailLogView.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

348 lines
9.9 KiB
Swift

//
// EmailLogView.swift
// oAI
//
// Email handler activity log viewer
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import SwiftUI
struct EmailLogView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared
private let emailLogService = EmailLogService.shared
@State private var logs: [EmailLog] = []
@State private var searchText = ""
@State private var showClearConfirmation = false
var body: some View {
VStack(spacing: 0) {
// Title and controls
header
Divider()
// Statistics
statistics
Divider()
// Search bar
searchBar
// Logs list
logsList
Divider()
// Bottom actions
bottomActions
}
.frame(width: 800, height: 600)
.onAppear {
loadLogs()
}
}
// MARK: - Header
private var header: some View {
HStack {
Text("Email Activity Log")
.font(.system(size: 18, weight: .bold))
Spacer()
Button("Done") {
dismiss()
}
.keyboardShortcut(.defaultAction)
}
.padding()
}
// MARK: - Statistics
private var statistics: some View {
let stats = emailLogService.getStatistics()
return HStack(spacing: 30) {
EmailStatItem(title: "Total", value: "\(stats.total)", color: .blue)
EmailStatItem(title: "Successful", value: "\(stats.successful)", color: .green)
EmailStatItem(title: "Errors", value: "\(stats.errors)", color: .red)
if stats.total > 0 {
EmailStatItem(
title: "Success Rate",
value: String(format: "%.1f%%", stats.successRate * 100),
color: .green
)
}
if let avgTime = stats.averageResponseTime {
EmailStatItem(
title: "Avg Response Time",
value: String(format: "%.1fs", avgTime),
color: .orange
)
}
if stats.totalCost > 0 {
EmailStatItem(
title: "Total Cost",
value: String(format: "$%.4f", stats.totalCost),
color: .purple
)
}
}
.padding()
}
// MARK: - Search Bar
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search by sender, subject, or content...", text: $searchText)
.textFieldStyle(.plain)
.onChange(of: searchText) {
filterLogs()
}
if !searchText.isEmpty {
Button(action: {
searchText = ""
loadLogs()
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(8)
.background(Color.secondary.opacity(0.1))
.cornerRadius(8)
.padding(.horizontal)
.padding(.vertical, 8)
}
// MARK: - Logs List
private var logsList: some View {
ScrollView {
LazyVStack(spacing: 8) {
if logs.isEmpty {
emptyState
} else {
ForEach(logs) { log in
EmailLogRow(log: log)
}
}
}
.padding()
}
}
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "tray")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No email activity yet")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.secondary)
if !settings.emailHandlerEnabled {
Text("Enable email handler in Settings to start monitoring emails")
.font(.system(size: 14))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
// MARK: - Bottom Actions
private var bottomActions: some View {
HStack {
Text("^[\(logs.count) entry](inflect: true)")
.font(.system(size: 13))
.foregroundColor(.secondary)
Spacer()
Button(action: {
showClearConfirmation = true
}) {
HStack(spacing: 4) {
Image(systemName: "trash")
Text("Clear All")
}
}
.disabled(logs.isEmpty)
.alert("Clear Email Log?", isPresented: $showClearConfirmation) {
Button("Cancel", role: .cancel) {}
Button("Clear All", role: .destructive) {
emailLogService.clearAllLogs()
loadLogs()
}
} message: {
Text("This will permanently delete all email activity logs. This action cannot be undone.")
}
}
.padding()
}
// MARK: - Data Operations
private func loadLogs() {
logs = emailLogService.loadLogs(limit: 500)
}
private func filterLogs() {
if searchText.isEmpty {
loadLogs()
} else {
let allLogs = emailLogService.loadLogs(limit: 500)
logs = allLogs.filter { log in
log.sender.localizedCaseInsensitiveContains(searchText) ||
log.subject.localizedCaseInsensitiveContains(searchText) ||
log.emailContent.localizedCaseInsensitiveContains(searchText) ||
(log.aiResponse?.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
}
}
// MARK: - Email Log Row
struct EmailLogRow: View {
let log: EmailLog
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Header: status, sender, time
HStack {
Image(systemName: log.status.iconName)
.foregroundColor(statusColor)
Text(log.sender)
.font(.system(size: 14, weight: .medium))
Spacer()
Text(log.timestamp.formatted(date: .abbreviated, time: .shortened))
.font(.system(size: 12))
.foregroundColor(.secondary)
}
// Subject
Text(log.subject)
.font(.system(size: 13))
.foregroundColor(.primary)
.lineLimit(1)
// Content preview
if log.status == .success, let response = log.aiResponse {
Text(response)
.font(.system(size: 12))
.foregroundColor(.secondary)
.lineLimit(2)
} else if log.status == .error, let error = log.errorMessage {
Text("Error: \(error)")
.font(.system(size: 12))
.foregroundColor(.red)
.lineLimit(2)
}
// Metadata
HStack(spacing: 12) {
if let model = log.modelId {
Label(model, systemImage: "cpu")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
if let tokens = log.tokens {
Label("\(tokens) tokens", systemImage: "number")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
if let cost = log.cost, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
if let responseTime = log.responseTime {
Label(String(format: "%.1fs", responseTime), systemImage: "clock")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
}
.padding(12)
.background(Color.secondary.opacity(0.05))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(statusColor.opacity(0.3), lineWidth: 1)
)
}
private var statusColor: Color {
switch log.status {
case .success: return .green
case .error: return .red
}
}
}
// MARK: - Stat Item
struct EmailStatItem: View {
let title: LocalizedStringKey
let value: String
let color: Color
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 20, weight: .bold))
.foregroundColor(color)
Text(title)
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
}
#Preview {
EmailLogView()
}