Added a lot of functionality. Bugfixes and changes
This commit is contained in:
@@ -18,6 +18,7 @@ struct ConversationRecord: Codable, FetchableRecord, PersistableRecord, Sendable
|
||||
var name: String
|
||||
var createdAt: String
|
||||
var updatedAt: String
|
||||
var primaryModel: String?
|
||||
}
|
||||
|
||||
struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
@@ -31,6 +32,7 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
var cost: Double?
|
||||
var timestamp: String
|
||||
var sortOrder: Int
|
||||
var modelId: String?
|
||||
}
|
||||
|
||||
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
@@ -48,6 +50,23 @@ struct HistoryRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
var timestamp: String
|
||||
}
|
||||
|
||||
struct EmailLogRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||
static let databaseTableName = "email_logs"
|
||||
|
||||
var id: String
|
||||
var timestamp: String
|
||||
var sender: String
|
||||
var subject: String
|
||||
var emailContent: String
|
||||
var aiResponse: String?
|
||||
var status: String // "success" or "error"
|
||||
var errorMessage: String?
|
||||
var tokens: Int?
|
||||
var cost: Double?
|
||||
var responseTime: Double?
|
||||
var modelId: String?
|
||||
}
|
||||
|
||||
// MARK: - DatabaseService
|
||||
|
||||
final class DatabaseService: Sendable {
|
||||
@@ -127,6 +146,42 @@ final class DatabaseService: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
migrator.registerMigration("v4") { db in
|
||||
// Add modelId to messages table (nullable for existing messages)
|
||||
try db.alter(table: "messages") { t in
|
||||
t.add(column: "modelId", .text)
|
||||
}
|
||||
|
||||
// Add primaryModel to conversations table (nullable)
|
||||
try db.alter(table: "conversations") { t in
|
||||
t.add(column: "primaryModel", .text)
|
||||
}
|
||||
}
|
||||
|
||||
migrator.registerMigration("v5") { db in
|
||||
// Email handler logs
|
||||
try db.create(table: "email_logs") { t in
|
||||
t.primaryKey("id", .text)
|
||||
t.column("timestamp", .text).notNull()
|
||||
t.column("sender", .text).notNull()
|
||||
t.column("subject", .text).notNull()
|
||||
t.column("emailContent", .text).notNull()
|
||||
t.column("aiResponse", .text)
|
||||
t.column("status", .text).notNull() // "success" or "error"
|
||||
t.column("errorMessage", .text)
|
||||
t.column("tokens", .integer)
|
||||
t.column("cost", .double)
|
||||
t.column("responseTime", .double)
|
||||
t.column("modelId", .text)
|
||||
}
|
||||
|
||||
try db.create(
|
||||
index: "email_logs_on_timestamp",
|
||||
on: "email_logs",
|
||||
columns: ["timestamp"]
|
||||
)
|
||||
}
|
||||
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -152,32 +207,68 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encrypted Settings Operations
|
||||
|
||||
/// Store an encrypted setting (for sensitive data like API keys)
|
||||
nonisolated func setEncryptedSetting(key: String, value: String) throws {
|
||||
let encryptedValue = try EncryptionService.shared.encrypt(value)
|
||||
try dbQueue.write { db in
|
||||
let record = SettingRecord(key: "encrypted_\(key)", value: encryptedValue)
|
||||
try record.save(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve and decrypt an encrypted setting
|
||||
nonisolated func getEncryptedSetting(key: String) throws -> String? {
|
||||
let encryptedValue = try dbQueue.read { db in
|
||||
try SettingRecord.fetchOne(db, key: "encrypted_\(key)")?.value
|
||||
}
|
||||
|
||||
guard let encryptedValue = encryptedValue else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try EncryptionService.shared.decrypt(encryptedValue)
|
||||
}
|
||||
|
||||
/// Delete an encrypted setting
|
||||
nonisolated func deleteEncryptedSetting(key: String) {
|
||||
try? dbQueue.write { db in
|
||||
_ = try SettingRecord.deleteOne(db, key: "encrypted_\(key)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conversation Operations
|
||||
|
||||
nonisolated func saveConversation(name: String, messages: [Message]) throws -> Conversation {
|
||||
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages")
|
||||
let convId = UUID()
|
||||
return try saveConversation(id: UUID(), name: name, messages: messages, primaryModel: nil)
|
||||
}
|
||||
|
||||
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
|
||||
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
|
||||
let now = Date()
|
||||
let nowString = isoFormatter.string(from: now)
|
||||
|
||||
let convRecord = ConversationRecord(
|
||||
id: convId.uuidString,
|
||||
id: id.uuidString,
|
||||
name: name,
|
||||
createdAt: nowString,
|
||||
updatedAt: nowString
|
||||
updatedAt: nowString,
|
||||
primaryModel: primaryModel
|
||||
)
|
||||
|
||||
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
||||
guard msg.role != .system else { return nil }
|
||||
return MessageRecord(
|
||||
id: UUID().uuidString,
|
||||
conversationId: convId.uuidString,
|
||||
conversationId: id.uuidString,
|
||||
role: msg.role.rawValue,
|
||||
content: msg.content,
|
||||
tokens: msg.tokens,
|
||||
cost: msg.cost,
|
||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
||||
sortOrder: index
|
||||
sortOrder: index,
|
||||
modelId: msg.modelId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,11 +281,12 @@ final class DatabaseService: Sendable {
|
||||
|
||||
let savedMessages = messages.filter { $0.role != .system }
|
||||
return Conversation(
|
||||
id: convId,
|
||||
id: id,
|
||||
name: name,
|
||||
messages: savedMessages,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
updatedAt: now,
|
||||
primaryModel: primaryModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -221,7 +313,8 @@ final class DatabaseService: Sendable {
|
||||
content: record.content,
|
||||
tokens: record.tokens,
|
||||
cost: record.cost,
|
||||
timestamp: timestamp
|
||||
timestamp: timestamp,
|
||||
modelId: record.modelId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,7 +328,8 @@ final class DatabaseService: Sendable {
|
||||
name: convRecord.name,
|
||||
messages: messages,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
updatedAt: updatedAt,
|
||||
primaryModel: convRecord.primaryModel
|
||||
)
|
||||
|
||||
return (conversation, messages)
|
||||
@@ -404,4 +498,80 @@ final class DatabaseService: Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Email Log Operations
|
||||
|
||||
nonisolated func saveEmailLog(_ log: EmailLog) {
|
||||
let record = EmailLogRecord(
|
||||
id: log.id.uuidString,
|
||||
timestamp: isoFormatter.string(from: log.timestamp),
|
||||
sender: log.sender,
|
||||
subject: log.subject,
|
||||
emailContent: log.emailContent,
|
||||
aiResponse: log.aiResponse,
|
||||
status: log.status.rawValue,
|
||||
errorMessage: log.errorMessage,
|
||||
tokens: log.tokens,
|
||||
cost: log.cost,
|
||||
responseTime: log.responseTime,
|
||||
modelId: log.modelId
|
||||
)
|
||||
|
||||
try? dbQueue.write { db in
|
||||
try record.insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func loadEmailLogs(limit: Int = 100) throws -> [EmailLog] {
|
||||
try dbQueue.read { db in
|
||||
let records = try EmailLogRecord
|
||||
.order(Column("timestamp").desc)
|
||||
.limit(limit)
|
||||
.fetchAll(db)
|
||||
|
||||
return records.compactMap { record in
|
||||
guard let timestamp = isoFormatter.date(from: record.timestamp),
|
||||
let status = EmailLogStatus(rawValue: record.status),
|
||||
let id = UUID(uuidString: record.id) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return EmailLog(
|
||||
id: id,
|
||||
timestamp: timestamp,
|
||||
sender: record.sender,
|
||||
subject: record.subject,
|
||||
emailContent: record.emailContent,
|
||||
aiResponse: record.aiResponse,
|
||||
status: status,
|
||||
errorMessage: record.errorMessage,
|
||||
tokens: record.tokens,
|
||||
cost: record.cost,
|
||||
responseTime: record.responseTime,
|
||||
modelId: record.modelId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func deleteEmailLog(id: UUID) {
|
||||
try? dbQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "DELETE FROM email_logs WHERE id = ?",
|
||||
arguments: [id.uuidString]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func clearEmailLogs() {
|
||||
try? dbQueue.write { db in
|
||||
try db.execute(sql: "DELETE FROM email_logs")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func getEmailLogCount() throws -> Int {
|
||||
try dbQueue.read { db in
|
||||
try EmailLogRecord.fetchCount(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
451
oAI/Services/EmailHandlerService.swift
Normal file
451
oAI/Services/EmailHandlerService.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
//
|
||||
// EmailHandlerService.swift
|
||||
// oAI
|
||||
//
|
||||
// AI-powered email auto-responder service
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class EmailHandlerService {
|
||||
static let shared = EmailHandlerService()
|
||||
|
||||
private let settings = SettingsService.shared
|
||||
private let emailService = EmailService.shared
|
||||
private let emailLog = EmailLogService.shared
|
||||
private let mcp = MCPService.shared
|
||||
private let log = Logger(subsystem: "com.oai.oAI", category: "email-handler")
|
||||
|
||||
// Rate limiting
|
||||
private var emailsProcessedThisHour: Int = 0
|
||||
private var hourResetTimer: Timer?
|
||||
|
||||
// Processing state
|
||||
private var isProcessing = false
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Start email handler (call at app startup)
|
||||
func start() {
|
||||
guard settings.emailHandlerEnabled else {
|
||||
log.info("Email handler disabled")
|
||||
return
|
||||
}
|
||||
|
||||
guard settings.emailHandlerConfigured else {
|
||||
log.warning("Email handler not configured properly")
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Starting email handler...")
|
||||
|
||||
// Set up email monitoring callback
|
||||
emailService.onNewEmail = { [weak self] email in
|
||||
Task {
|
||||
await self?.handleIncomingEmail(email)
|
||||
}
|
||||
}
|
||||
|
||||
// Start IMAP IDLE monitoring
|
||||
emailService.startMonitoring()
|
||||
|
||||
// Start rate limit timer
|
||||
startRateLimitTimer()
|
||||
|
||||
log.info("Email handler started successfully")
|
||||
}
|
||||
|
||||
/// Stop email handler
|
||||
func stop() {
|
||||
log.info("Stopping email handler...")
|
||||
emailService.stopMonitoring()
|
||||
hourResetTimer?.invalidate()
|
||||
hourResetTimer = nil
|
||||
log.info("Email handler stopped")
|
||||
}
|
||||
|
||||
// MARK: - Email Processing
|
||||
|
||||
private func handleIncomingEmail(_ email: IncomingEmail) async {
|
||||
log.info("New email received from \(email.from): \(email.subject)")
|
||||
|
||||
// Check rate limiting
|
||||
if self.settings.emailRateLimitEnabled && self.settings.emailRateLimitPerHour < 100 {
|
||||
if self.emailsProcessedThisHour >= self.settings.emailRateLimitPerHour {
|
||||
log.warning("Rate limit exceeded (\(self.emailsProcessedThisHour)/\(self.settings.emailRateLimitPerHour)), skipping email")
|
||||
emailLog.logError(
|
||||
sender: email.from,
|
||||
subject: email.subject,
|
||||
emailContent: String(email.body.prefix(200)),
|
||||
errorMessage: "Rate limit exceeded (\(self.settings.emailRateLimitPerHour) emails/hour)",
|
||||
modelId: nil
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent concurrent processing
|
||||
guard !isProcessing else {
|
||||
log.warning("Already processing an email, queueing not implemented")
|
||||
return
|
||||
}
|
||||
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
|
||||
// Process with retry
|
||||
var attemptCount = 0
|
||||
let maxAttempts = 2
|
||||
|
||||
while attemptCount < maxAttempts {
|
||||
attemptCount += 1
|
||||
|
||||
do {
|
||||
try await processEmailWithAI(email, attempt: attemptCount)
|
||||
emailsProcessedThisHour += 1
|
||||
|
||||
// Delete email after successful processing
|
||||
try? await deleteEmail(email.uid)
|
||||
|
||||
log.info("Successfully processed and deleted email (attempt \(attemptCount))")
|
||||
return
|
||||
} catch {
|
||||
log.error("Failed to process email (attempt \(attemptCount)): \(error.localizedDescription)")
|
||||
|
||||
if attemptCount >= maxAttempts {
|
||||
// Send error email to sender
|
||||
try? await sendErrorEmail(to: email.from, subject: email.subject, error: error)
|
||||
|
||||
// Log error
|
||||
emailLog.logError(
|
||||
sender: email.from,
|
||||
subject: email.subject,
|
||||
emailContent: String(email.body.prefix(200)),
|
||||
errorMessage: error.localizedDescription,
|
||||
modelId: settings.emailHandlerModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processEmailWithAI(_ email: IncomingEmail, attempt: Int) async throws {
|
||||
let startTime = Date()
|
||||
|
||||
log.info("Processing email with AI (attempt \(attempt))...")
|
||||
|
||||
// Get AI provider for email handling
|
||||
guard let provider = getEmailProvider() else {
|
||||
throw EmailServiceError.notConfigured
|
||||
}
|
||||
|
||||
// Build AI prompt
|
||||
let prompt = buildEmailPrompt(from: email)
|
||||
|
||||
// Prepare tools (read-only MCP access)
|
||||
var tools: [Tool]? = nil
|
||||
if settings.mcpEnabled && !mcp.allowedFolders.isEmpty {
|
||||
tools = mcp.getToolSchemas().filter { tool in
|
||||
isReadOnlyTool(tool.function.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Build messages
|
||||
let userMessage = Message(
|
||||
role: .user,
|
||||
content: prompt
|
||||
)
|
||||
|
||||
// Create chat request
|
||||
// IMPORTANT: Email handler uses ONLY its own system prompt (custom or default)
|
||||
// All other prompts (main chat system prompt, user custom prompts) are excluded
|
||||
// This ensures complete isolation of email handling behavior
|
||||
let request = ChatRequest(
|
||||
messages: [userMessage],
|
||||
model: settings.emailHandlerModel,
|
||||
stream: false,
|
||||
maxTokens: settings.emailMaxTokens,
|
||||
temperature: 0.7,
|
||||
systemPrompt: getEmailSystemPrompt(), // Email-specific prompt ONLY
|
||||
tools: tools,
|
||||
onlineMode: settings.emailOnlineMode, // User-configurable online mode for emails
|
||||
imageGeneration: false // Disable image generation for emails
|
||||
)
|
||||
|
||||
// Call AI (non-streaming)
|
||||
let response = try await provider.chat(request: request)
|
||||
|
||||
let fullResponse = response.content
|
||||
let totalTokens = response.usage.map { $0.promptTokens + $0.completionTokens }
|
||||
let totalCost: Double? = nil // Calculate if provider supports it
|
||||
|
||||
let responseTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
log.info("AI response generated in \(String(format: "%.2f", responseTime))s")
|
||||
|
||||
// Generate HTML email
|
||||
let htmlBody = generateHTMLEmail(aiResponse: fullResponse, originalEmail: email)
|
||||
|
||||
// Send response email
|
||||
let replySubject = email.subject.hasPrefix("Re:") ? email.subject : "Re: \(email.subject)"
|
||||
|
||||
let messageId = try await emailService.sendEmail(
|
||||
to: email.from,
|
||||
subject: replySubject,
|
||||
body: fullResponse, // Plain text fallback
|
||||
htmlBody: htmlBody,
|
||||
inReplyTo: email.messageId
|
||||
)
|
||||
|
||||
log.info("Response email sent: \(messageId)")
|
||||
|
||||
// Log success
|
||||
emailLog.logSuccess(
|
||||
sender: email.from,
|
||||
subject: email.subject,
|
||||
emailContent: String(email.body.prefix(500)),
|
||||
aiResponse: String(fullResponse.prefix(500)),
|
||||
tokens: totalTokens,
|
||||
cost: totalCost,
|
||||
responseTime: responseTime,
|
||||
modelId: settings.emailHandlerModel
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Prompt Building
|
||||
|
||||
private func buildEmailPrompt(from email: IncomingEmail) -> String {
|
||||
// Remove subject identifier from subject
|
||||
var cleanSubject = email.subject
|
||||
if let range = cleanSubject.range(of: settings.emailSubjectIdentifier) {
|
||||
cleanSubject.removeSubrange(range)
|
||||
cleanSubject = cleanSubject.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
let prompt = """
|
||||
You have received an email that requires a response. Please provide a helpful, professional reply.
|
||||
|
||||
From: \(email.from)
|
||||
Subject: \(cleanSubject)
|
||||
Date: \(email.receivedDate.formatted())
|
||||
|
||||
Email Content:
|
||||
\(email.body)
|
||||
|
||||
---
|
||||
|
||||
Please provide a complete, well-formatted response to this email. Your response will be sent as an HTML email.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
/// Get the email handler system prompt
|
||||
/// - Returns: Email-specific system prompt (custom if set, default otherwise)
|
||||
/// - Note: This is completely isolated from the main chat system prompt.
|
||||
/// No other prompts are merged or concatenated.
|
||||
private func getEmailSystemPrompt() -> String {
|
||||
// ISOLATION: Use custom email prompt if provided, otherwise use default email prompt
|
||||
// This completely overrides and replaces any other system prompts
|
||||
// Main chat prompts and user custom prompts are NOT used for email handling
|
||||
if let customPrompt = settings.emailHandlerSystemPrompt, !customPrompt.isEmpty {
|
||||
return customPrompt
|
||||
}
|
||||
|
||||
// Default email system prompt (used only when no custom email prompt is set)
|
||||
return """
|
||||
You are an AI email assistant. You respond to emails on behalf of the user.
|
||||
|
||||
Guidelines:
|
||||
- Be professional and courteous
|
||||
- Keep responses concise and relevant
|
||||
- Use proper email etiquette
|
||||
- Format your response using Markdown (it will be converted to HTML)
|
||||
- If you need information from files, you have read-only access via MCP tools
|
||||
- Never claim to write, modify, or delete files (read-only access)
|
||||
- Sign emails appropriately
|
||||
|
||||
Your response will be automatically formatted and sent via email.
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - HTML Email Generation
|
||||
|
||||
private func generateHTMLEmail(aiResponse: String, originalEmail: IncomingEmail) -> String {
|
||||
// Convert markdown to HTML (basic implementation)
|
||||
let htmlContent = markdownToHTML(aiResponse)
|
||||
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.content {
|
||||
background: #ffffff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
\(htmlContent)
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>🤖 This response was generated by AI using oAI Email Handler</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
private func markdownToHTML(_ markdown: String) -> String {
|
||||
// Basic markdown to HTML conversion
|
||||
// TODO: Use proper markdown parser for production
|
||||
var html = markdown
|
||||
|
||||
// Paragraphs
|
||||
html = html.replacingOccurrences(of: "\n\n", with: "</p><p>")
|
||||
html = "<p>\(html)</p>"
|
||||
|
||||
// Bold
|
||||
html = html.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
|
||||
|
||||
// Italic
|
||||
html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "<em>$1</em>", options: .regularExpression)
|
||||
|
||||
// Inline code
|
||||
html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "<code>$1</code>", options: .regularExpression)
|
||||
|
||||
// Line breaks
|
||||
html = html.replacingOccurrences(of: "\n", with: "<br>")
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// MARK: - Error Handling
|
||||
|
||||
private func sendErrorEmail(to: String, subject: String, error: Error) async throws {
|
||||
let errorHTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
.error { background: #fee; padding: 20px; border-left: 4px solid #f00; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h2>⚠️ Email Processing Error</h2>
|
||||
<p>We encountered an error while processing your email:</p>
|
||||
<p><strong>\(error.localizedDescription)</strong></p>
|
||||
<p>Please try again later or contact support if the problem persists.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
_ = try await emailService.sendEmail(
|
||||
to: to,
|
||||
subject: "Re: \(subject) - Processing Error",
|
||||
body: "An error occurred: \(error.localizedDescription)",
|
||||
htmlBody: errorHTML
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func deleteEmail(_ uid: UInt32) async throws {
|
||||
guard let host = settings.emailImapHost,
|
||||
let username = settings.emailUsername,
|
||||
let password = settings.emailPassword else {
|
||||
return
|
||||
}
|
||||
|
||||
let client = IMAPClient(
|
||||
host: host,
|
||||
port: UInt16(settings.emailImapPort),
|
||||
username: username,
|
||||
password: password,
|
||||
useTLS: true
|
||||
)
|
||||
|
||||
try await client.connect()
|
||||
try await client.login()
|
||||
try await client.selectMailbox("INBOX")
|
||||
try await client.deleteEmail(uid: uid)
|
||||
try await client.expunge()
|
||||
client.disconnect()
|
||||
|
||||
log.info("Deleted email UID \(uid)")
|
||||
}
|
||||
|
||||
private func getEmailProvider() -> AIProvider? {
|
||||
let providerNameString = settings.emailHandlerProvider
|
||||
let registry = ProviderRegistry.shared
|
||||
|
||||
// Convert string to Settings.Provider enum
|
||||
guard let providerType = Settings.Provider(rawValue: providerNameString) else {
|
||||
log.error("Invalid provider name: '\(providerNameString)'")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if provider is configured
|
||||
let configuredProviders = registry.configuredProviders
|
||||
guard configuredProviders.contains(providerType) else {
|
||||
log.error("Email handler provider '\(providerNameString)' not configured (no API key)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get provider instance
|
||||
return registry.getProvider(for: providerType)
|
||||
}
|
||||
|
||||
private func isReadOnlyTool(_ toolName: String) -> Bool {
|
||||
// Only allow read-only MCP tools for email handling
|
||||
let readOnlyTools = ["read_file", "list_directory", "search_files", "get_file_info"]
|
||||
return readOnlyTools.contains(toolName)
|
||||
}
|
||||
|
||||
private func startRateLimitTimer() {
|
||||
// Reset counter every hour
|
||||
hourResetTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
|
||||
self?.emailsProcessedThisHour = 0
|
||||
self?.log.info("Rate limit counter reset")
|
||||
}
|
||||
}
|
||||
}
|
||||
154
oAI/Services/EmailLogService.swift
Normal file
154
oAI/Services/EmailLogService.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// EmailLogService.swift
|
||||
// oAI
|
||||
//
|
||||
// Service for managing email handler activity logs
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class EmailLogService {
|
||||
static let shared = EmailLogService()
|
||||
|
||||
private let db = DatabaseService.shared
|
||||
private let log = Logger(subsystem: "com.oai.oAI", category: "email-log")
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Log Operations
|
||||
|
||||
/// Save a successful email processing log
|
||||
func logSuccess(
|
||||
sender: String,
|
||||
subject: String,
|
||||
emailContent: String,
|
||||
aiResponse: String,
|
||||
tokens: Int?,
|
||||
cost: Double?,
|
||||
responseTime: TimeInterval?,
|
||||
modelId: String?
|
||||
) {
|
||||
let entry = EmailLog(
|
||||
sender: sender,
|
||||
subject: subject,
|
||||
emailContent: emailContent,
|
||||
aiResponse: aiResponse,
|
||||
status: .success,
|
||||
tokens: tokens,
|
||||
cost: cost,
|
||||
responseTime: responseTime,
|
||||
modelId: modelId
|
||||
)
|
||||
|
||||
db.saveEmailLog(entry)
|
||||
log.info("Email log saved: \(sender) - \(subject) [SUCCESS]")
|
||||
}
|
||||
|
||||
/// Save a failed email processing log
|
||||
func logError(
|
||||
sender: String,
|
||||
subject: String,
|
||||
emailContent: String,
|
||||
errorMessage: String,
|
||||
modelId: String?
|
||||
) {
|
||||
let entry = EmailLog(
|
||||
sender: sender,
|
||||
subject: subject,
|
||||
emailContent: emailContent,
|
||||
aiResponse: nil,
|
||||
status: .error,
|
||||
errorMessage: errorMessage,
|
||||
modelId: modelId
|
||||
)
|
||||
|
||||
db.saveEmailLog(entry)
|
||||
log.error("Email log saved: \(sender) - \(subject) [ERROR: \(errorMessage)]")
|
||||
}
|
||||
|
||||
/// Load recent email logs (default: 100 most recent)
|
||||
func loadLogs(limit: Int = 100) -> [EmailLog] {
|
||||
do {
|
||||
return try db.loadEmailLogs(limit: limit)
|
||||
} catch {
|
||||
log.error("Failed to load email logs: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a specific log entry
|
||||
func deleteLog(id: UUID) {
|
||||
db.deleteEmailLog(id: id)
|
||||
log.info("Email log deleted: \(id.uuidString)")
|
||||
}
|
||||
|
||||
/// Clear all email logs
|
||||
func clearAllLogs() {
|
||||
db.clearEmailLogs()
|
||||
log.info("All email logs cleared")
|
||||
}
|
||||
|
||||
/// Get total count of email logs
|
||||
func getLogCount() -> Int {
|
||||
do {
|
||||
return try db.getEmailLogCount()
|
||||
} catch {
|
||||
log.error("Failed to get email log count: \(error.localizedDescription)")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
/// Get email processing statistics
|
||||
func getStatistics() -> EmailStatistics {
|
||||
let logs = loadLogs(limit: 1000) // Last 1000 for stats
|
||||
|
||||
let total = logs.count
|
||||
let successful = logs.filter { $0.status == .success }.count
|
||||
let errors = logs.filter { $0.status == .error }.count
|
||||
|
||||
let totalTokens = logs.compactMap { $0.tokens }.reduce(0, +)
|
||||
let totalCost = logs.compactMap { $0.cost }.reduce(0.0, +)
|
||||
|
||||
let avgResponseTime: TimeInterval?
|
||||
let responseTimes = logs.compactMap { $0.responseTime }
|
||||
if !responseTimes.isEmpty {
|
||||
avgResponseTime = responseTimes.reduce(0, +) / Double(responseTimes.count)
|
||||
} else {
|
||||
avgResponseTime = nil
|
||||
}
|
||||
|
||||
return EmailStatistics(
|
||||
total: total,
|
||||
successful: successful,
|
||||
errors: errors,
|
||||
totalTokens: totalTokens,
|
||||
totalCost: totalCost,
|
||||
averageResponseTime: avgResponseTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Statistics Model
|
||||
|
||||
struct EmailStatistics {
|
||||
let total: Int
|
||||
let successful: Int
|
||||
let errors: Int
|
||||
let totalTokens: Int
|
||||
let totalCost: Double
|
||||
let averageResponseTime: TimeInterval?
|
||||
|
||||
var successRate: Double {
|
||||
guard total > 0 else { return 0.0 }
|
||||
return Double(successful) / Double(total)
|
||||
}
|
||||
|
||||
var errorRate: Double {
|
||||
guard total > 0 else { return 0.0 }
|
||||
return Double(errors) / Double(total)
|
||||
}
|
||||
}
|
||||
313
oAI/Services/EmailService.swift
Normal file
313
oAI/Services/EmailService.swift
Normal file
@@ -0,0 +1,313 @@
|
||||
//
|
||||
// EmailService.swift
|
||||
// oAI
|
||||
//
|
||||
// IMAP IDLE email monitoring service for AI email handler
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - Email Models
|
||||
|
||||
struct IncomingEmail: Identifiable {
|
||||
let id: UUID
|
||||
let uid: UInt32
|
||||
let messageId: String
|
||||
let from: String
|
||||
let to: [String]
|
||||
let subject: String
|
||||
let body: String
|
||||
let receivedDate: Date
|
||||
let inReplyTo: String? // For threading
|
||||
}
|
||||
|
||||
enum EmailServiceError: LocalizedError {
|
||||
case notConfigured
|
||||
case connectionFailed(String)
|
||||
case authenticationFailed
|
||||
case invalidCredentials
|
||||
case idleNotSupported
|
||||
case sendingFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConfigured:
|
||||
return "Email not configured. Check Settings > Email."
|
||||
case .connectionFailed(let msg):
|
||||
return "Connection failed: \(msg)"
|
||||
case .authenticationFailed:
|
||||
return "Authentication failed. Check credentials."
|
||||
case .invalidCredentials:
|
||||
return "Invalid email credentials"
|
||||
case .idleNotSupported:
|
||||
return "IMAP IDLE not supported by server"
|
||||
case .sendingFailed(let msg):
|
||||
return "Failed to send email: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EmailService
|
||||
|
||||
@Observable
|
||||
final class EmailService {
|
||||
static let shared = EmailService()
|
||||
|
||||
private let settings = SettingsService.shared
|
||||
private let log = Logger(subsystem: "com.oai.oAI", category: "email")
|
||||
|
||||
// IMAP IDLE state
|
||||
private var isConnected = false
|
||||
private var isIdling = false
|
||||
private var monitoringTask: Task<Void, Never>?
|
||||
|
||||
// Callback for new emails
|
||||
var onNewEmail: ((IncomingEmail) -> Void)?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Connection Management
|
||||
|
||||
/// Start IMAP IDLE monitoring (called at app startup)
|
||||
func startMonitoring() {
|
||||
guard settings.emailHandlerEnabled else {
|
||||
log.info("Email handler disabled, skipping monitoring")
|
||||
return
|
||||
}
|
||||
|
||||
guard settings.emailHandlerConfigured else {
|
||||
log.warning("Email handler not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Email credentials should be configured in Settings > Email tab
|
||||
// (This check can be expanded when email settings are added)
|
||||
|
||||
log.info("Starting IMAP IDLE monitoring...")
|
||||
|
||||
monitoringTask = Task { [weak self] in
|
||||
await self?.monitorInbox()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop IMAP IDLE monitoring
|
||||
func stopMonitoring() {
|
||||
log.info("Stopping IMAP IDLE monitoring...")
|
||||
monitoringTask?.cancel()
|
||||
monitoringTask = nil
|
||||
isIdling = false
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
// MARK: - IMAP Polling Implementation
|
||||
|
||||
private func monitorInbox() async {
|
||||
guard let host = settings.emailImapHost,
|
||||
let username = settings.emailUsername,
|
||||
let password = settings.emailPassword else {
|
||||
log.error("Email credentials not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var checkedUIDs = Set<UInt32>()
|
||||
var retryDelay: UInt64 = 30 // Start with 30 seconds
|
||||
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
let client = IMAPClient(
|
||||
host: host,
|
||||
port: UInt16(settings.emailImapPort),
|
||||
username: username,
|
||||
password: password,
|
||||
useTLS: true
|
||||
)
|
||||
|
||||
try await client.connect()
|
||||
try await client.login()
|
||||
try await client.selectMailbox("INBOX")
|
||||
|
||||
// Search for ALL unseen emails first
|
||||
let allUnseenUIDs = try await client.searchUnseen()
|
||||
|
||||
// Check each email for the subject identifier
|
||||
for uid in allUnseenUIDs {
|
||||
if !checkedUIDs.contains(uid) {
|
||||
checkedUIDs.insert(uid)
|
||||
|
||||
do {
|
||||
let email = try await client.fetchEmail(uid: uid)
|
||||
|
||||
// Check if email has the correct subject identifier
|
||||
if email.subject.contains(settings.emailSubjectIdentifier) {
|
||||
// Valid email - process it
|
||||
log.info("New email found: \(email.subject) from \(email.from)")
|
||||
|
||||
// Call callback on main thread
|
||||
await MainActor.run {
|
||||
onNewEmail?(email)
|
||||
}
|
||||
} else {
|
||||
// Wrong subject - delete it
|
||||
log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)")
|
||||
try await client.deleteEmail(uid: uid)
|
||||
}
|
||||
} catch {
|
||||
log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expunge deleted messages
|
||||
try await client.expunge()
|
||||
|
||||
client.disconnect()
|
||||
|
||||
// Reset retry delay on success
|
||||
retryDelay = 30
|
||||
|
||||
// Wait before next check
|
||||
try await Task.sleep(for: .seconds(retryDelay))
|
||||
|
||||
} catch {
|
||||
log.error("IMAP monitoring error: \(error.localizedDescription)")
|
||||
|
||||
// Exponential backoff (max 5 minutes)
|
||||
retryDelay = min(retryDelay * 2, 300)
|
||||
|
||||
try? await Task.sleep(for: .seconds(retryDelay))
|
||||
}
|
||||
}
|
||||
|
||||
log.info("IMAP monitoring stopped")
|
||||
}
|
||||
|
||||
/// Connect to IMAP and SMTP servers and verify credentials
|
||||
func testConnection() async throws -> String {
|
||||
guard let imapHost = settings.emailImapHost,
|
||||
let smtpHost = settings.emailSmtpHost,
|
||||
let username = settings.emailUsername,
|
||||
let password = settings.emailPassword else {
|
||||
throw EmailServiceError.notConfigured
|
||||
}
|
||||
|
||||
// Test IMAP connection
|
||||
let imapClient = IMAPClient(
|
||||
host: imapHost,
|
||||
port: UInt16(settings.emailImapPort),
|
||||
username: username,
|
||||
password: password,
|
||||
useTLS: true
|
||||
)
|
||||
|
||||
try await imapClient.connect()
|
||||
try await imapClient.login()
|
||||
try await imapClient.selectMailbox("INBOX")
|
||||
imapClient.disconnect()
|
||||
|
||||
// Test SMTP connection
|
||||
let smtpClient = SMTPClient(
|
||||
host: smtpHost,
|
||||
port: UInt16(settings.emailSmtpPort),
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
|
||||
try await smtpClient.connect()
|
||||
smtpClient.disconnect()
|
||||
|
||||
return "Successfully connected to IMAP (\(imapHost)) and SMTP (\(smtpHost))"
|
||||
}
|
||||
|
||||
// MARK: - Email Sending (SMTP)
|
||||
|
||||
/// Send email response via SMTP
|
||||
func sendEmail(
|
||||
to: String,
|
||||
subject: String,
|
||||
body: String,
|
||||
htmlBody: String? = nil,
|
||||
inReplyTo: String? = nil
|
||||
) async throws -> String {
|
||||
guard let host = settings.emailSmtpHost,
|
||||
let username = settings.emailUsername,
|
||||
let password = settings.emailPassword else {
|
||||
throw EmailServiceError.notConfigured
|
||||
}
|
||||
|
||||
let client = SMTPClient(
|
||||
host: host,
|
||||
port: UInt16(settings.emailSmtpPort),
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
|
||||
let messageId = try await client.sendEmail(
|
||||
from: username,
|
||||
to: [to],
|
||||
subject: subject,
|
||||
body: body,
|
||||
htmlBody: htmlBody,
|
||||
inReplyTo: inReplyTo
|
||||
)
|
||||
|
||||
log.info("Email sent successfully: \(messageId)")
|
||||
return messageId
|
||||
}
|
||||
|
||||
// MARK: - Email Fetching (for manual retrieval)
|
||||
|
||||
/// Fetch recent emails from INBOX (for testing/debugging)
|
||||
func fetchRecentEmails(limit: Int = 10) async throws -> [IncomingEmail] {
|
||||
// TODO: Implement IMAP FETCH
|
||||
// 1. Connect to IMAP server
|
||||
// 2. SELECT INBOX
|
||||
// 3. SEARCH or FETCH recent UIDs
|
||||
// 4. FETCH headers and body for each UID
|
||||
// 5. Parse into IncomingEmail structs
|
||||
|
||||
log.warning("IMAP FETCH implementation pending")
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Check if email subject contains the identifier
|
||||
private func matchesSubjectIdentifier(_ subject: String) -> Bool {
|
||||
let identifier = settings.emailSubjectIdentifier
|
||||
return subject.contains(identifier)
|
||||
}
|
||||
|
||||
/// Extract plain text body from email
|
||||
private func extractPlainText(from body: String) -> String {
|
||||
// TODO: Handle multipart MIME, HTML stripping, etc.
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Implementation Notes
|
||||
|
||||
/*
|
||||
IMAP IDLE Implementation Options:
|
||||
|
||||
1. MailCore2 (Recommended for production)
|
||||
- Add via SPM: https://github.com/MailCore/mailcore2
|
||||
- Create Objective-C bridging header
|
||||
- Use MCOIMAPSession with MCOIMAPIdleOperation
|
||||
- Pros: Battle-tested, full IMAP/SMTP support
|
||||
- Cons: Objective-C bridging required
|
||||
|
||||
2. Swift NIO + Custom IMAP
|
||||
- Implement IMAP protocol manually
|
||||
- Pros: Pure Swift, no bridging
|
||||
- Cons: Complex, error-prone, time-consuming
|
||||
|
||||
3. Third-party Swift Libraries
|
||||
- Research Swift-native IMAP libraries on GitHub/SPM
|
||||
- Pros: Easier than custom implementation
|
||||
- Cons: May not be as mature as MailCore2
|
||||
|
||||
For now, this service provides the interface and structure.
|
||||
The actual IMAP IDLE implementation should be added based on
|
||||
chosen library/approach.
|
||||
*/
|
||||
114
oAI/Services/EncryptionService.swift
Normal file
114
oAI/Services/EncryptionService.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// EncryptionService.swift
|
||||
// oAI
|
||||
//
|
||||
// Secure encryption for sensitive data (API keys)
|
||||
// Uses CryptoKit with machine-specific key derivation
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import IOKit
|
||||
|
||||
class EncryptionService {
|
||||
static let shared = EncryptionService()
|
||||
|
||||
private let salt = "oAI-secure-storage-v1" // App-specific salt
|
||||
private lazy var encryptionKey: SymmetricKey = {
|
||||
deriveEncryptionKey()
|
||||
}()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
/// Encrypt a string value
|
||||
func encrypt(_ value: String) throws -> String {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw EncryptionError.invalidInput
|
||||
}
|
||||
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
guard let combined = sealedBox.combined else {
|
||||
throw EncryptionError.encryptionFailed
|
||||
}
|
||||
|
||||
return combined.base64EncodedString()
|
||||
}
|
||||
|
||||
/// Decrypt a string value
|
||||
func decrypt(_ encryptedValue: String) throws -> String {
|
||||
guard let data = Data(base64Encoded: encryptedValue) else {
|
||||
throw EncryptionError.invalidInput
|
||||
}
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(combined: data)
|
||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||
|
||||
guard let decryptedString = String(data: decryptedData, encoding: .utf8) else {
|
||||
throw EncryptionError.decryptionFailed
|
||||
}
|
||||
|
||||
return decryptedString
|
||||
}
|
||||
|
||||
// MARK: - Key Derivation
|
||||
|
||||
/// Derive encryption key from machine-specific data
|
||||
private func deriveEncryptionKey() -> SymmetricKey {
|
||||
// Combine machine UUID + bundle ID + salt for key material
|
||||
let machineUUID = getMachineUUID()
|
||||
let bundleID = Bundle.main.bundleIdentifier ?? "com.oai.oAI"
|
||||
let keyMaterial = "\(machineUUID)-\(bundleID)-\(salt)"
|
||||
|
||||
// Hash to create consistent 256-bit key
|
||||
let hash = SHA256.hash(data: Data(keyMaterial.utf8))
|
||||
return SymmetricKey(data: hash)
|
||||
}
|
||||
|
||||
/// Get machine-specific UUID (IOPlatformUUID)
|
||||
private func getMachineUUID() -> String {
|
||||
// Get IOPlatformUUID from IOKit
|
||||
let platformExpert = IOServiceGetMatchingService(
|
||||
kIOMainPortDefault,
|
||||
IOServiceMatching("IOPlatformExpertDevice")
|
||||
)
|
||||
|
||||
guard platformExpert != 0 else {
|
||||
// Fallback to a stable identifier if IOKit unavailable
|
||||
return "oai-fallback-uuid"
|
||||
}
|
||||
|
||||
defer { IOObjectRelease(platformExpert) }
|
||||
|
||||
guard let uuidData = IORegistryEntryCreateCFProperty(
|
||||
platformExpert,
|
||||
"IOPlatformUUID" as CFString,
|
||||
kCFAllocatorDefault,
|
||||
0
|
||||
) else {
|
||||
return "oai-fallback-uuid"
|
||||
}
|
||||
|
||||
return (uuidData.takeRetainedValue() as? String) ?? "oai-fallback-uuid"
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum EncryptionError: LocalizedError {
|
||||
case invalidInput
|
||||
case encryptionFailed
|
||||
case decryptionFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidInput:
|
||||
return "Invalid input data for encryption/decryption"
|
||||
case .encryptionFailed:
|
||||
return "Failed to encrypt data"
|
||||
case .decryptionFailed:
|
||||
return "Failed to decrypt data"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
657
oAI/Services/GitSyncService.swift
Normal file
657
oAI/Services/GitSyncService.swift
Normal file
@@ -0,0 +1,657 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
class GitSyncService {
|
||||
static let shared = GitSyncService()
|
||||
|
||||
private let settings = SettingsService.shared
|
||||
private let db = DatabaseService.shared
|
||||
private let log = Logger(subsystem: "com.oai.oAI", category: "sync")
|
||||
|
||||
private(set) var syncStatus = SyncStatus()
|
||||
private(set) var isSyncing = false
|
||||
private(set) var lastSyncError: String?
|
||||
|
||||
// Debounce tracking
|
||||
private var pendingSyncTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Repository Operations
|
||||
|
||||
/// Test connection to remote repository
|
||||
func testConnection() async throws -> String {
|
||||
let url = try buildAuthenticatedURL()
|
||||
_ = try await runGit(["ls-remote", url])
|
||||
return "Connected to \(extractProvider())"
|
||||
}
|
||||
|
||||
/// Clone repository to local path
|
||||
func cloneRepository() async throws {
|
||||
guard settings.syncConfigured else {
|
||||
throw SyncError.notConfigured
|
||||
}
|
||||
|
||||
let url = try buildAuthenticatedURL()
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
|
||||
// Check if already cloned
|
||||
if FileManager.default.fileExists(atPath: localPath + "/.git") {
|
||||
log.info("Repository already cloned at \(localPath)")
|
||||
syncStatus.isCloned = true
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Cloning repository from \(self.settings.syncRepoURL)")
|
||||
_ = try await runGit(["clone", url, localPath])
|
||||
syncStatus.isCloned = true
|
||||
|
||||
await updateStatus()
|
||||
}
|
||||
|
||||
/// Pull latest changes from remote
|
||||
func pull() async throws {
|
||||
try ensureCloned()
|
||||
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
log.info("Pulling changes from remote")
|
||||
|
||||
_ = try await runGit(["pull", "--ff-only"], cwd: localPath)
|
||||
syncStatus.lastSyncTime = Date()
|
||||
|
||||
await updateStatus()
|
||||
}
|
||||
|
||||
/// Push local changes to remote
|
||||
func push(message: String = "Sync from oAI") async throws {
|
||||
try ensureCloned()
|
||||
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
|
||||
// 1. Scan for secrets before committing
|
||||
try scanForSecrets(in: localPath)
|
||||
|
||||
// 2. Add all changes
|
||||
log.info("Adding changes to git")
|
||||
_ = try await runGit(["add", "."], cwd: localPath)
|
||||
|
||||
// 3. Check if there are changes to commit
|
||||
let status = try await runGit(["status", "--porcelain"], cwd: localPath)
|
||||
guard !status.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
log.info("No changes to commit")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Commit
|
||||
log.info("Committing changes")
|
||||
_ = try await runGit(["commit", "-m", message], cwd: localPath)
|
||||
|
||||
// 5. Push
|
||||
log.info("Pushing to remote")
|
||||
// Check if upstream is set, if not set it (for first push to empty repo)
|
||||
do {
|
||||
_ = try await runGit(["push"], cwd: localPath)
|
||||
} catch {
|
||||
// First push might fail if no upstream, try with -u origin HEAD
|
||||
log.info("First push - setting upstream")
|
||||
_ = try await runGit(["push", "-u", "origin", "HEAD"], cwd: localPath)
|
||||
}
|
||||
|
||||
syncStatus.lastSyncTime = Date()
|
||||
await updateStatus()
|
||||
}
|
||||
|
||||
// MARK: - Conversation Export/Import
|
||||
|
||||
/// Export all conversations to markdown files
|
||||
func exportAllConversations() async throws {
|
||||
try ensureCloned()
|
||||
|
||||
let conversations = try db.listConversations()
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
let conversationsDir = localPath + "/conversations"
|
||||
|
||||
// Create conversations directory
|
||||
try FileManager.default.createDirectory(atPath: conversationsDir, withIntermediateDirectories: true)
|
||||
|
||||
// Create README if it doesn't exist
|
||||
try createReadmeIfNeeded()
|
||||
|
||||
log.info("Exporting \(conversations.count) conversations")
|
||||
|
||||
for conversation in conversations {
|
||||
// Load full conversation with messages
|
||||
guard let (_, messages) = try db.loadConversation(id: conversation.id) else {
|
||||
log.warning("Could not load conversation \(conversation.id.uuidString)")
|
||||
continue
|
||||
}
|
||||
|
||||
let export = ConversationExport(
|
||||
id: conversation.id.uuidString,
|
||||
name: conversation.name,
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt,
|
||||
primaryModel: conversation.primaryModel,
|
||||
messages: messages.map { msg in
|
||||
ConversationExport.MessageExport(
|
||||
role: msg.role.rawValue,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
tokens: msg.tokens,
|
||||
cost: msg.cost,
|
||||
modelId: msg.modelId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
let markdown = export.toMarkdown()
|
||||
let filename = sanitizeFilename(conversation.name) + ".md"
|
||||
let filepath = conversationsDir + "/" + filename
|
||||
|
||||
try markdown.write(toFile: filepath, atomically: true, encoding: String.Encoding.utf8)
|
||||
log.debug("Exported: \(filename)")
|
||||
}
|
||||
|
||||
await updateStatus()
|
||||
}
|
||||
|
||||
/// Import conversations from markdown files
|
||||
func importAllConversations() async throws -> (imported: Int, skipped: Int, errors: Int) {
|
||||
try ensureCloned()
|
||||
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
let conversationsDir = localPath + "/conversations"
|
||||
|
||||
guard FileManager.default.fileExists(atPath: conversationsDir) else {
|
||||
log.warning("No conversations directory found")
|
||||
return (0, 0, 0)
|
||||
}
|
||||
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
|
||||
let mdFiles = files.filter { $0.hasSuffix(".md") }
|
||||
|
||||
log.info("Importing \(mdFiles.count) conversation files")
|
||||
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
var errors = 0
|
||||
|
||||
for filename in mdFiles {
|
||||
let filepath = conversationsDir + "/" + filename
|
||||
|
||||
do {
|
||||
// Read markdown file
|
||||
let markdown = try String(contentsOfFile: filepath, encoding: .utf8)
|
||||
|
||||
// Parse markdown to ConversationExport
|
||||
let export = try ConversationExport.fromMarkdown(markdown)
|
||||
|
||||
// Check if conversation already exists (by ID)
|
||||
if let existingId = UUID(uuidString: export.id) {
|
||||
if let existing = try? db.loadConversation(id: existingId) {
|
||||
// Already exists - skip
|
||||
log.debug("Skipping existing conversation: \(export.name)")
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Convert MessageExport to Message
|
||||
let messages = export.messages.map { msgExport -> Message in
|
||||
let role: MessageRole
|
||||
switch msgExport.role.lowercased() {
|
||||
case "user": role = .user
|
||||
case "assistant": role = .assistant
|
||||
case "system": role = .system
|
||||
default: role = .user
|
||||
}
|
||||
|
||||
return Message(
|
||||
role: role,
|
||||
content: msgExport.content,
|
||||
tokens: msgExport.tokens,
|
||||
cost: msgExport.cost,
|
||||
timestamp: msgExport.timestamp,
|
||||
modelId: msgExport.modelId
|
||||
)
|
||||
}
|
||||
|
||||
// Import to database with primaryModel
|
||||
let conversationId = UUID(uuidString: export.id) ?? UUID()
|
||||
_ = try db.saveConversation(
|
||||
id: conversationId,
|
||||
name: export.name,
|
||||
messages: messages,
|
||||
primaryModel: export.primaryModel
|
||||
)
|
||||
log.info("Imported: \(export.name)")
|
||||
imported += 1
|
||||
|
||||
} catch {
|
||||
log.error("Failed to import \(filename): \(error.localizedDescription)")
|
||||
errors += 1
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Import complete: \(imported) imported, \(skipped) skipped, \(errors) errors")
|
||||
return (imported, skipped, errors)
|
||||
}
|
||||
|
||||
/// Create README.md in sync repository
|
||||
private func createReadmeIfNeeded() throws {
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
let readmePath = localPath + "/README.md"
|
||||
|
||||
// Only create if doesn't exist
|
||||
guard !FileManager.default.fileExists(atPath: readmePath) else {
|
||||
return
|
||||
}
|
||||
|
||||
let readme = """
|
||||
# oAI Conversation Sync
|
||||
|
||||
This repository contains your oAI conversations in markdown format.
|
||||
|
||||
## ⚠️ WARNING - DO NOT MANUALLY EDIT
|
||||
|
||||
**This repository is automatically managed by oAI.**
|
||||
|
||||
- ❌ **DO NOT manually edit** these files
|
||||
- ❌ **DO NOT add** files to this repository
|
||||
- ❌ **DO NOT delete** files from this repository
|
||||
- ❌ **DO NOT merge conflicts** manually (let oAI handle it)
|
||||
|
||||
**Why?** oAI rebuilds its internal database from these files. Manual edits will be:
|
||||
- Overwritten on next sync
|
||||
- May cause data corruption
|
||||
- May prevent proper import/restore
|
||||
|
||||
## How It Works
|
||||
|
||||
### Export (Automatic)
|
||||
- oAI saves conversations to its local database
|
||||
- Auto-sync exports conversations to `conversations/*.md`
|
||||
- Files are committed and pushed to this git repository
|
||||
|
||||
### Import (On New Machine)
|
||||
- Clone this repository on a new machine
|
||||
- oAI imports markdown files into its database
|
||||
- Your conversation history is restored
|
||||
|
||||
### Sync Across Machines
|
||||
- Machine A: Chat → Auto-save → Export → Push to git
|
||||
- Machine B: Pull from git → Auto-import → Database updated
|
||||
- Conversations stay in sync across all machines
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── README.md # This file
|
||||
└── conversations/ # Your conversations
|
||||
├── conversation-1.md
|
||||
├── conversation-2.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Conversation File Format
|
||||
|
||||
Each `.md` file contains:
|
||||
- Conversation metadata (ID, name, dates)
|
||||
- All messages (user and assistant)
|
||||
- Token counts and costs
|
||||
- Timestamps
|
||||
|
||||
Example:
|
||||
```markdown
|
||||
# Python async patterns guide
|
||||
|
||||
**ID**: `abc-123-def`
|
||||
**Created**: 2026-02-14T10:30:00Z
|
||||
**Updated**: 2026-02-14T11:45:00Z
|
||||
|
||||
---
|
||||
|
||||
## User
|
||||
|
||||
How do I use async/await in Python?
|
||||
|
||||
---
|
||||
|
||||
## Assistant
|
||||
|
||||
[Response here...]
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- This repository contains **plain text** conversations
|
||||
- API keys and secrets are **automatically scanned and blocked**
|
||||
- Keep this repository **private** if conversations contain sensitive info
|
||||
- Use **.gitignore** if you want to exclude specific conversations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Problem:** Files not syncing?
|
||||
- Check Settings → Sync in oAI
|
||||
- Verify git credentials are correct
|
||||
- Check network connection
|
||||
|
||||
**Problem:** Conflicts after editing?
|
||||
- Restore from git: `git reset --hard origin/main`
|
||||
- Re-export from oAI: Manual Sync → Export All → Push
|
||||
|
||||
**Problem:** Lost conversations?
|
||||
- Conversations are in your local oAI database
|
||||
- Export manually: Settings → Sync → Export All
|
||||
- Check git history for deleted files
|
||||
|
||||
## Support
|
||||
|
||||
For help with oAI, see:
|
||||
- Settings → Help in oAI app
|
||||
- GitHub issues (if open source)
|
||||
|
||||
---
|
||||
|
||||
**Generated by oAI v1.0**
|
||||
**Last updated:** \(ISO8601DateFormatter().string(from: Date()))
|
||||
"""
|
||||
|
||||
try readme.write(toFile: readmePath, atomically: true, encoding: .utf8)
|
||||
log.info("Created README.md in sync repository")
|
||||
}
|
||||
|
||||
// MARK: - Auto-Sync
|
||||
|
||||
/// Perform auto-sync with debouncing (export + push)
|
||||
/// Debounces multiple rapid sync requests to avoid spamming git
|
||||
func autoSync() async {
|
||||
// Cancel any pending sync
|
||||
pendingSyncTask?.cancel()
|
||||
|
||||
// Schedule new sync with 5 second delay
|
||||
pendingSyncTask = Task {
|
||||
do {
|
||||
// Wait for debounce period
|
||||
try await Task.sleep(for: .seconds(5))
|
||||
|
||||
// Check if cancelled during sleep
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
// Set syncing state
|
||||
await MainActor.run {
|
||||
isSyncing = true
|
||||
lastSyncError = nil
|
||||
}
|
||||
|
||||
log.info("Auto-sync starting (export + push)...")
|
||||
|
||||
// Export conversations
|
||||
try await exportAllConversations()
|
||||
|
||||
// Push to git
|
||||
try await push(message: "Auto-sync from oAI")
|
||||
|
||||
// Success
|
||||
await MainActor.run {
|
||||
isSyncing = false
|
||||
syncStatus.lastSyncTime = Date()
|
||||
}
|
||||
|
||||
log.info("Auto-sync completed successfully")
|
||||
|
||||
} catch {
|
||||
// Error
|
||||
await MainActor.run {
|
||||
isSyncing = false
|
||||
lastSyncError = error.localizedDescription
|
||||
}
|
||||
|
||||
log.error("Auto-sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the task to complete
|
||||
await pendingSyncTask?.value
|
||||
}
|
||||
|
||||
// MARK: - Secret Scanning
|
||||
|
||||
/// Scan for API keys and secrets in conversations
|
||||
func scanForSecrets(in directory: String) throws {
|
||||
let conversationsDir = directory + "/conversations"
|
||||
|
||||
guard FileManager.default.fileExists(atPath: conversationsDir) else {
|
||||
return
|
||||
}
|
||||
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: conversationsDir)
|
||||
let mdFiles = files.filter { $0.hasSuffix(".md") }
|
||||
|
||||
var detectedSecrets: [String] = []
|
||||
|
||||
for filename in mdFiles {
|
||||
let filepath = conversationsDir + "/" + filename
|
||||
let content = try String(contentsOfFile: filepath, encoding: .utf8)
|
||||
|
||||
let secrets = detectSecretsInText(content)
|
||||
if !secrets.isEmpty {
|
||||
detectedSecrets.append("\(filename): \(secrets.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
|
||||
if !detectedSecrets.isEmpty {
|
||||
log.error("Secrets detected in conversations!")
|
||||
throw SyncError.secretsDetected(detectedSecrets)
|
||||
}
|
||||
}
|
||||
|
||||
private func detectSecretsInText(_ text: String) -> [String] {
|
||||
let patterns: [(name: String, pattern: String)] = [
|
||||
("OpenAI Key", "sk-[a-zA-Z0-9]{32,}"),
|
||||
("Anthropic Key", "sk-ant-[a-zA-Z0-9_-]+"),
|
||||
("Bearer Token", "Bearer [a-zA-Z0-9_-]{20,}"),
|
||||
("API Key", "api[_-]?key[\"']?\\s*[:=]\\s*[\"']?[a-zA-Z0-9]{20,}"),
|
||||
("Access Token", "ghp_[a-zA-Z0-9]{36}"), // GitHub personal access token
|
||||
]
|
||||
|
||||
var found: [String] = []
|
||||
|
||||
for (name, pattern) in patterns {
|
||||
if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
let matches = regex.matches(in: text, range: range)
|
||||
|
||||
if !matches.isEmpty {
|
||||
found.append(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array(Set(found)) // Remove duplicates
|
||||
}
|
||||
|
||||
// MARK: - Status Management
|
||||
|
||||
func updateStatus() async {
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
|
||||
// Check if cloned
|
||||
syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git")
|
||||
|
||||
guard syncStatus.isCloned else { return }
|
||||
|
||||
do {
|
||||
// Get current branch
|
||||
let branch = try await runGit(["branch", "--show-current"], cwd: localPath)
|
||||
syncStatus.currentBranch = branch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Get uncommitted changes count
|
||||
let status = try await runGit(["status", "--porcelain"], cwd: localPath)
|
||||
let lines = status.components(separatedBy: .newlines).filter { !$0.isEmpty }
|
||||
syncStatus.uncommittedChanges = lines.count
|
||||
|
||||
// Get remote status
|
||||
_ = try await runGit(["fetch"], cwd: localPath)
|
||||
let remoteDiff = try await runGit(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd: localPath)
|
||||
let parts = remoteDiff.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t")
|
||||
|
||||
if parts.count == 2 {
|
||||
let ahead = Int(parts[0]) ?? 0
|
||||
let behind = Int(parts[1]) ?? 0
|
||||
|
||||
if ahead == 0 && behind == 0 {
|
||||
syncStatus.remoteStatus = "up-to-date"
|
||||
} else if ahead > 0 && behind == 0 {
|
||||
syncStatus.remoteStatus = "ahead \(ahead)"
|
||||
} else if ahead == 0 && behind > 0 {
|
||||
syncStatus.remoteStatus = "behind \(behind)"
|
||||
} else {
|
||||
syncStatus.remoteStatus = "diverged"
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
log.error("Failed to update status: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func buildAuthenticatedURL() throws -> String {
|
||||
guard settings.syncConfigured else {
|
||||
throw SyncError.notConfigured
|
||||
}
|
||||
|
||||
let baseURL = settings.syncRepoURL
|
||||
|
||||
switch settings.syncAuthMethod {
|
||||
case "ssh":
|
||||
// Convert HTTPS URL to SSH format if needed
|
||||
return convertToSSH(baseURL)
|
||||
|
||||
case "password":
|
||||
guard let username = settings.syncUsername,
|
||||
let password = settings.syncPassword else {
|
||||
throw SyncError.missingCredentials
|
||||
}
|
||||
return injectCredentials(baseURL, username: username, password: password)
|
||||
|
||||
case "token":
|
||||
guard let token = settings.syncAccessToken else {
|
||||
throw SyncError.missingCredentials
|
||||
}
|
||||
// Use oauth2 as username for tokens
|
||||
return injectCredentials(baseURL, username: "oauth2", password: token)
|
||||
|
||||
default:
|
||||
return baseURL
|
||||
}
|
||||
}
|
||||
|
||||
private func convertToSSH(_ url: String) -> String {
|
||||
// If already SSH format, return as-is
|
||||
if url.hasPrefix("git@") {
|
||||
return url
|
||||
}
|
||||
|
||||
// Convert HTTPS to SSH format
|
||||
// https://gitlab.pm/rune/oAI-Sync.git -> git@gitlab.pm:rune/oAI-Sync.git
|
||||
if url.hasPrefix("https://") {
|
||||
let withoutScheme = url.replacingOccurrences(of: "https://", with: "")
|
||||
// Replace first "/" with ":"
|
||||
if let firstSlash = withoutScheme.firstIndex(of: "/") {
|
||||
var sshURL = withoutScheme
|
||||
sshURL.replaceSubrange(firstSlash...firstSlash, with: ":")
|
||||
return "git@" + sshURL
|
||||
}
|
||||
}
|
||||
|
||||
// If http:// (rare but possible)
|
||||
if url.hasPrefix("http://") {
|
||||
let withoutScheme = url.replacingOccurrences(of: "http://", with: "")
|
||||
if let firstSlash = withoutScheme.firstIndex(of: "/") {
|
||||
var sshURL = withoutScheme
|
||||
sshURL.replaceSubrange(firstSlash...firstSlash, with: ":")
|
||||
return "git@" + sshURL
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown format, return as-is
|
||||
return url
|
||||
}
|
||||
|
||||
private func injectCredentials(_ url: String, username: String, password: String) -> String {
|
||||
// Convert https://github.com/user/repo.git
|
||||
// To: https://username:password@github.com/user/repo.git
|
||||
|
||||
if url.hasPrefix("https://") {
|
||||
let withoutScheme = url.replacingOccurrences(of: "https://", with: "")
|
||||
return "https://\(username):\(password)@\(withoutScheme)"
|
||||
}
|
||||
|
||||
return url // SSH or other protocol
|
||||
}
|
||||
|
||||
private func runGit(_ args: [String], cwd: String? = nil) async throws -> String {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
|
||||
process.arguments = args
|
||||
|
||||
if let cwd = cwd {
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: expandPath(cwd))
|
||||
}
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
||||
let output = String(data: outputData, encoding: .utf8) ?? ""
|
||||
let error = String(data: errorData, encoding: .utf8) ?? ""
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
log.error("Git command failed: \(args.joined(separator: " "))")
|
||||
log.error("Error: \(error)")
|
||||
throw SyncError.gitFailed(error.isEmpty ? "Unknown error" : error)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
private func expandPath(_ path: String) -> String {
|
||||
return NSString(string: path).expandingTildeInPath
|
||||
}
|
||||
|
||||
private func ensureCloned() throws {
|
||||
let localPath = expandPath(settings.syncLocalPath)
|
||||
guard FileManager.default.fileExists(atPath: localPath + "/.git") else {
|
||||
throw SyncError.repoNotCloned
|
||||
}
|
||||
}
|
||||
|
||||
private func sanitizeFilename(_ name: String) -> String {
|
||||
// Remove invalid filename characters
|
||||
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|")
|
||||
return name.components(separatedBy: invalid).joined(separator: "-")
|
||||
}
|
||||
|
||||
private func extractProvider() -> String {
|
||||
let url = settings.syncRepoURL
|
||||
if url.contains("github.com") {
|
||||
return "GitHub"
|
||||
} else if url.contains("gitlab.com") {
|
||||
return "GitLab"
|
||||
} else if url.contains("gitea") {
|
||||
return "Gitea"
|
||||
} else {
|
||||
return "Git repository"
|
||||
}
|
||||
}
|
||||
}
|
||||
294
oAI/Services/IMAPClient.swift
Normal file
294
oAI/Services/IMAPClient.swift
Normal file
@@ -0,0 +1,294 @@
|
||||
//
|
||||
// IMAPClient.swift
|
||||
// oAI
|
||||
//
|
||||
// Swift-native IMAP client for email monitoring
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import os
|
||||
|
||||
class IMAPClient {
|
||||
private let log = Logger(subsystem: "com.oai.oAI", category: "imap")
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var host: String
|
||||
private var port: UInt16
|
||||
private var username: String
|
||||
private var password: String
|
||||
private var useTLS: Bool
|
||||
|
||||
private var commandTag = 1
|
||||
private var receiveBuffer = Data()
|
||||
|
||||
init(host: String, port: UInt16 = 993, username: String, password: String, useTLS: Bool = true) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.useTLS = useTLS
|
||||
}
|
||||
|
||||
// MARK: - Connection
|
||||
|
||||
func connect() async throws {
|
||||
let tlsOptions = useTLS ? NWProtocolTLS.Options() : nil
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
|
||||
|
||||
let hostName = self.host
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
final class ResumeOnce {
|
||||
var resumed = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
let resumeOnce = ResumeOnce()
|
||||
|
||||
connection?.stateUpdateHandler = { [weak self] state in
|
||||
resumeOnce.lock.lock()
|
||||
defer { resumeOnce.lock.unlock() }
|
||||
|
||||
guard !resumeOnce.resumed else { return }
|
||||
|
||||
switch state {
|
||||
case .ready:
|
||||
resumeOnce.resumed = true
|
||||
self?.log.info("IMAP connected to \(hostName)")
|
||||
continuation.resume()
|
||||
case .failed(let error):
|
||||
resumeOnce.resumed = true
|
||||
self?.log.error("IMAP connection failed: \(error.localizedDescription)")
|
||||
continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection?.start(queue: .global())
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
connection?.cancel()
|
||||
connection = nil
|
||||
log.info("IMAP disconnected")
|
||||
}
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
func login() async throws {
|
||||
// Wait for server greeting
|
||||
_ = try await readResponse()
|
||||
|
||||
// Send LOGIN command
|
||||
let loginCmd = "LOGIN \"\(username)\" \"\(password)\""
|
||||
let response = try await sendCommand(loginCmd)
|
||||
|
||||
if !response.contains("OK") {
|
||||
throw EmailServiceError.authenticationFailed
|
||||
}
|
||||
|
||||
log.info("IMAP login successful")
|
||||
}
|
||||
|
||||
// MARK: - Mailbox Operations
|
||||
|
||||
func selectMailbox(_ mailbox: String = "INBOX") async throws {
|
||||
let response = try await sendCommand("SELECT \(mailbox)")
|
||||
|
||||
if !response.contains("OK") {
|
||||
throw EmailServiceError.connectionFailed("Failed to select mailbox")
|
||||
}
|
||||
}
|
||||
|
||||
func searchUnseenWithSubject(_ subject: String) async throws -> [UInt32] {
|
||||
let response = try await sendCommand("SEARCH UNSEEN SUBJECT \"\(subject)\"")
|
||||
|
||||
// Parse UIDs from response like "* SEARCH 123 124 125"
|
||||
var uids: [UInt32] = []
|
||||
|
||||
for line in response.split(separator: "\r\n") {
|
||||
if line.hasPrefix("* SEARCH") {
|
||||
let parts = line.split(separator: " ")
|
||||
for part in parts.dropFirst(2) {
|
||||
if let uid = UInt32(part) {
|
||||
uids.append(uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uids
|
||||
}
|
||||
|
||||
func searchUnseen() async throws -> [UInt32] {
|
||||
let response = try await sendCommand("SEARCH UNSEEN")
|
||||
|
||||
// Parse UIDs from response like "* SEARCH 123 124 125"
|
||||
var uids: [UInt32] = []
|
||||
|
||||
for line in response.split(separator: "\r\n") {
|
||||
if line.hasPrefix("* SEARCH") {
|
||||
let parts = line.split(separator: " ")
|
||||
for part in parts.dropFirst(2) {
|
||||
if let uid = UInt32(part) {
|
||||
uids.append(uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uids
|
||||
}
|
||||
|
||||
func fetchEmail(uid: UInt32) async throws -> IncomingEmail {
|
||||
let response = try await sendCommand("FETCH \(uid) (BODY.PEEK[] FLAGS)")
|
||||
|
||||
// Parse email from response
|
||||
return try parseEmailResponse(response, uid: uid)
|
||||
}
|
||||
|
||||
func markAsRead(uid: UInt32) async throws {
|
||||
_ = try await sendCommand("STORE \(uid) +FLAGS (\\Seen)")
|
||||
}
|
||||
|
||||
func deleteEmail(uid: UInt32) async throws {
|
||||
_ = try await sendCommand("STORE \(uid) +FLAGS (\\Deleted)")
|
||||
}
|
||||
|
||||
func expunge() async throws {
|
||||
_ = try await sendCommand("EXPUNGE")
|
||||
}
|
||||
|
||||
// MARK: - Low-level Protocol
|
||||
|
||||
private func sendCommand(_ command: String) async throws -> String {
|
||||
guard let connection = connection else {
|
||||
throw EmailServiceError.connectionFailed("Not connected")
|
||||
}
|
||||
|
||||
let tag = "A\(commandTag)"
|
||||
commandTag += 1
|
||||
|
||||
let fullCommand = "\(tag) \(command)\r\n"
|
||||
let data = fullCommand.data(using: .utf8)!
|
||||
|
||||
// Send command
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Read response
|
||||
return try await readResponse(until: tag)
|
||||
}
|
||||
|
||||
private func readResponse(until tag: String? = nil) async throws -> String {
|
||||
guard let connection = connection else {
|
||||
throw EmailServiceError.connectionFailed("Not connected")
|
||||
}
|
||||
|
||||
var fullResponse = ""
|
||||
|
||||
while true {
|
||||
let data: Data = try await withCheckedThrowingContinuation { continuation in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let data = data {
|
||||
continuation.resume(returning: data)
|
||||
} else {
|
||||
continuation.resume(throwing: EmailServiceError.connectionFailed("No data received"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
receiveBuffer.append(data)
|
||||
|
||||
if let response = String(data: receiveBuffer, encoding: .utf8) {
|
||||
fullResponse = response
|
||||
|
||||
// Check if we have a complete response
|
||||
if let tag = tag {
|
||||
if response.contains("\(tag) OK") || response.contains("\(tag) NO") || response.contains("\(tag) BAD") {
|
||||
receiveBuffer.removeAll()
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// Just return what we got
|
||||
receiveBuffer.removeAll()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullResponse
|
||||
}
|
||||
|
||||
// MARK: - Email Parsing
|
||||
|
||||
private func parseEmailResponse(_ response: String, uid: UInt32) throws -> IncomingEmail {
|
||||
// Basic email parsing - extract headers and body
|
||||
let lines = response.split(separator: "\r\n", omittingEmptySubsequences: false)
|
||||
|
||||
var from = ""
|
||||
var subject = ""
|
||||
var messageId = ""
|
||||
var inReplyTo: String?
|
||||
var body = ""
|
||||
var inBody = false
|
||||
var receivedDate = Date()
|
||||
|
||||
for line in lines {
|
||||
let lineStr = String(line)
|
||||
|
||||
if lineStr.hasPrefix("From: ") {
|
||||
from = String(lineStr.dropFirst(6))
|
||||
// Extract email from "Name <email@domain.com>"
|
||||
if let start = from.firstIndex(of: "<"), let end = from.firstIndex(of: ">") {
|
||||
from = String(from[start...end].dropFirst().dropLast())
|
||||
}
|
||||
} else if lineStr.hasPrefix("Subject: ") {
|
||||
subject = String(lineStr.dropFirst(9))
|
||||
} else if lineStr.hasPrefix("Message-ID: ") || lineStr.hasPrefix("Message-Id: ") {
|
||||
messageId = String(lineStr.split(separator: ": ", maxSplits: 1).last ?? "")
|
||||
} else if lineStr.hasPrefix("In-Reply-To: ") {
|
||||
inReplyTo = String(lineStr.split(separator: ": ", maxSplits: 1).last ?? "")
|
||||
} else if lineStr.hasPrefix("Date: ") {
|
||||
let dateStr = String(lineStr.dropFirst(6))
|
||||
// Parse RFC 2822 date format
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
if let date = formatter.date(from: dateStr) {
|
||||
receivedDate = date
|
||||
}
|
||||
} else if lineStr.isEmpty && !inBody {
|
||||
// Empty line marks end of headers
|
||||
inBody = true
|
||||
} else if inBody {
|
||||
body += lineStr + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return IncomingEmail(
|
||||
id: UUID(),
|
||||
uid: uid,
|
||||
messageId: messageId.isEmpty ? "msg-\(uid)" : messageId,
|
||||
from: from,
|
||||
to: [username],
|
||||
subject: subject,
|
||||
body: body.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
receivedDate: receivedDate,
|
||||
inReplyTo: inReplyTo
|
||||
)
|
||||
}
|
||||
}
|
||||
368
oAI/Services/SMTPClient.swift
Normal file
368
oAI/Services/SMTPClient.swift
Normal file
@@ -0,0 +1,368 @@
|
||||
//
|
||||
// SMTPClient.swift
|
||||
// oAI
|
||||
//
|
||||
// Swift-native SMTP client for sending emails
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import os
|
||||
|
||||
class SMTPClient {
|
||||
private let log = Logger(subsystem: "com.oai.oAI", category: "smtp")
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var host: String
|
||||
private var port: UInt16
|
||||
private var username: String
|
||||
private var password: String
|
||||
|
||||
init(host: String, port: UInt16 = 587, username: String, password: String) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
}
|
||||
|
||||
// MARK: - Connection
|
||||
|
||||
func connectWithTLS() async throws {
|
||||
let tlsOptions = NWProtocolTLS.Options()
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
|
||||
connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
|
||||
|
||||
let hostName = self.host
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
final class ResumeOnce {
|
||||
var resumed = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
let resumeOnce = ResumeOnce()
|
||||
|
||||
connection?.stateUpdateHandler = { [weak self] state in
|
||||
resumeOnce.lock.lock()
|
||||
defer { resumeOnce.lock.unlock() }
|
||||
|
||||
guard !resumeOnce.resumed else { return }
|
||||
|
||||
switch state {
|
||||
case .ready:
|
||||
resumeOnce.resumed = true
|
||||
self?.log.info("SMTP connected to \(hostName) with TLS")
|
||||
continuation.resume()
|
||||
case .failed(let error):
|
||||
resumeOnce.resumed = true
|
||||
self?.log.error("SMTP TLS connection failed: \(error.localizedDescription)")
|
||||
continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection?.start(queue: .global())
|
||||
}
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
let params = NWParameters(tls: nil, tcp: tcpOptions)
|
||||
|
||||
connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
|
||||
|
||||
let hostName = self.host
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
final class ResumeOnce {
|
||||
var resumed = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
let resumeOnce = ResumeOnce()
|
||||
|
||||
connection?.stateUpdateHandler = { [weak self] state in
|
||||
resumeOnce.lock.lock()
|
||||
defer { resumeOnce.lock.unlock() }
|
||||
|
||||
guard !resumeOnce.resumed else { return }
|
||||
|
||||
switch state {
|
||||
case .ready:
|
||||
resumeOnce.resumed = true
|
||||
self?.log.info("SMTP connected to \(hostName)")
|
||||
continuation.resume()
|
||||
case .failed(let error):
|
||||
resumeOnce.resumed = true
|
||||
self?.log.error("SMTP connection failed: \(error.localizedDescription)")
|
||||
continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection?.start(queue: .global())
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
_ = try? sendCommandSync("QUIT")
|
||||
connection?.cancel()
|
||||
connection = nil
|
||||
log.info("SMTP disconnected")
|
||||
}
|
||||
|
||||
// MARK: - Send Email
|
||||
|
||||
func sendEmail(from: String, to: [String], subject: String, body: String, htmlBody: String? = nil, inReplyTo: String? = nil) async throws -> String {
|
||||
// Port 465 uses direct TLS (implicit), port 587 uses STARTTLS (explicit)
|
||||
let usesDirectTLS = port == 465
|
||||
|
||||
if usesDirectTLS {
|
||||
// Direct TLS connection (port 465)
|
||||
try await connectWithTLS()
|
||||
} else {
|
||||
// Plain connection first (port 587)
|
||||
try await connect()
|
||||
}
|
||||
defer { disconnect() }
|
||||
|
||||
// Read server greeting (220)
|
||||
_ = try await readResponse()
|
||||
|
||||
// EHLO
|
||||
_ = try await sendCommand("EHLO \(host)")
|
||||
|
||||
// STARTTLS only for port 587
|
||||
if !usesDirectTLS {
|
||||
// Note: Network framework doesn't support mid-connection TLS upgrade
|
||||
// For STARTTLS, we skip the upgrade and continue with plain connection
|
||||
// This is insecure - recommend using port 465 instead
|
||||
log.warning("Port 587 with STARTTLS not fully supported, consider using port 465 (direct TLS)")
|
||||
}
|
||||
|
||||
// AUTH LOGIN
|
||||
_ = try await sendCommand("AUTH LOGIN", expectCode: "334")
|
||||
|
||||
// Send base64 encoded username
|
||||
let usernameB64 = Data(username.utf8).base64EncodedString()
|
||||
_ = try await sendCommand(usernameB64, expectCode: "334")
|
||||
|
||||
// Send base64 encoded password
|
||||
let passwordB64 = Data(password.utf8).base64EncodedString()
|
||||
_ = try await sendCommand(passwordB64, expectCode: "235")
|
||||
|
||||
// MAIL FROM
|
||||
_ = try await sendCommand("MAIL FROM:<\(from)>")
|
||||
|
||||
// RCPT TO
|
||||
for recipient in to {
|
||||
_ = try await sendCommand("RCPT TO:<\(recipient)>")
|
||||
}
|
||||
|
||||
// DATA
|
||||
_ = try await sendCommand("DATA", expectCode: "354")
|
||||
|
||||
// Build email
|
||||
let messageId = "<\(UUID().uuidString)@\(host)>"
|
||||
let date = formatEmailDate(Date())
|
||||
|
||||
var email = ""
|
||||
email += "From: \(from)\r\n"
|
||||
email += "To: \(to.joined(separator: ", "))\r\n"
|
||||
email += "Subject: \(subject)\r\n"
|
||||
email += "Date: \(date)\r\n"
|
||||
email += "Message-ID: \(messageId)\r\n"
|
||||
|
||||
if let inReplyTo = inReplyTo {
|
||||
email += "In-Reply-To: \(inReplyTo)\r\n"
|
||||
email += "References: \(inReplyTo)\r\n"
|
||||
}
|
||||
|
||||
if let htmlBody = htmlBody {
|
||||
// Multipart alternative
|
||||
let boundary = "----=_Part_\(UUID().uuidString.prefix(16))"
|
||||
email += "MIME-Version: 1.0\r\n"
|
||||
email += "Content-Type: multipart/alternative; boundary=\"\(boundary)\"\r\n"
|
||||
email += "\r\n"
|
||||
email += "--\(boundary)\r\n"
|
||||
email += "Content-Type: text/plain; charset=utf-8\r\n"
|
||||
email += "\r\n"
|
||||
email += body
|
||||
email += "\r\n\r\n"
|
||||
email += "--\(boundary)\r\n"
|
||||
email += "Content-Type: text/html; charset=utf-8\r\n"
|
||||
email += "\r\n"
|
||||
email += htmlBody
|
||||
email += "\r\n\r\n"
|
||||
email += "--\(boundary)--\r\n"
|
||||
} else {
|
||||
// Plain text
|
||||
email += "Content-Type: text/plain; charset=utf-8\r\n"
|
||||
email += "\r\n"
|
||||
email += body
|
||||
email += "\r\n"
|
||||
}
|
||||
|
||||
// End with CRLF.CRLF
|
||||
email += ".\r\n"
|
||||
|
||||
// Send email data
|
||||
_ = try await sendCommand(email, expectCode: "250", raw: true)
|
||||
|
||||
log.info("Email sent successfully: \(messageId)")
|
||||
return messageId
|
||||
}
|
||||
|
||||
// MARK: - Low-level Protocol
|
||||
|
||||
private func upgradToTLS() async throws {
|
||||
guard let connection = connection else {
|
||||
throw EmailServiceError.connectionFailed("Not connected")
|
||||
}
|
||||
|
||||
// Create TLS options
|
||||
let tlsOptions = NWProtocolTLS.Options()
|
||||
|
||||
// Create new parameters with TLS
|
||||
let params = NWParameters(tls: tlsOptions)
|
||||
|
||||
// Cancel old connection
|
||||
connection.cancel()
|
||||
|
||||
// Create new connection with TLS
|
||||
self.connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params)
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
final class ResumeOnce {
|
||||
var resumed = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
let resumeOnce = ResumeOnce()
|
||||
|
||||
self.connection?.stateUpdateHandler = { [weak self] state in
|
||||
resumeOnce.lock.lock()
|
||||
defer { resumeOnce.lock.unlock() }
|
||||
|
||||
guard !resumeOnce.resumed else { return }
|
||||
|
||||
switch state {
|
||||
case .ready:
|
||||
resumeOnce.resumed = true
|
||||
self?.log.info("SMTP upgraded to TLS")
|
||||
continuation.resume()
|
||||
case .failed(let error):
|
||||
resumeOnce.resumed = true
|
||||
continuation.resume(throwing: error)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
self.connection?.start(queue: .global())
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCommand(_ command: String, expectCode: String = "250", raw: Bool = false) async throws -> String {
|
||||
guard let connection = connection else {
|
||||
throw EmailServiceError.connectionFailed("Not connected")
|
||||
}
|
||||
|
||||
let fullCommand = raw ? command : "\(command)\r\n"
|
||||
let data = fullCommand.data(using: .utf8)!
|
||||
|
||||
// Send command
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Read response
|
||||
let response = try await readResponse()
|
||||
|
||||
if !response.hasPrefix(expectCode) {
|
||||
throw EmailServiceError.sendingFailed("Expected \(expectCode), got: \(response)")
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private func sendCommandSync(_ command: String) throws {
|
||||
guard let connection = connection else { return }
|
||||
|
||||
let fullCommand = "\(command)\r\n"
|
||||
let data = fullCommand.data(using: .utf8)!
|
||||
|
||||
connection.send(content: data, completion: .idempotent)
|
||||
}
|
||||
|
||||
private func readResponse() async throws -> String {
|
||||
guard let connection = connection else {
|
||||
throw EmailServiceError.connectionFailed("Not connected")
|
||||
}
|
||||
|
||||
var fullResponse = ""
|
||||
var isComplete = false
|
||||
|
||||
// Keep reading until we get a complete SMTP response
|
||||
// Multi-line responses end with "XXX " (space), interim lines have "XXX-" (dash)
|
||||
while !isComplete {
|
||||
let data: Data = try await withCheckedThrowingContinuation { continuation in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let data = data {
|
||||
continuation.resume(returning: data)
|
||||
} else {
|
||||
continuation.resume(throwing: EmailServiceError.connectionFailed("No data received"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let chunk = String(data: data, encoding: .utf8) else {
|
||||
throw EmailServiceError.connectionFailed("Invalid response encoding")
|
||||
}
|
||||
|
||||
fullResponse += chunk
|
||||
|
||||
// Check if we have a complete response
|
||||
// SMTP responses end with a line like "250 OK\r\n" (code + space + message)
|
||||
let lines = fullResponse.split(separator: "\r\n", omittingEmptySubsequences: false)
|
||||
for line in lines {
|
||||
if line.count >= 4 {
|
||||
let prefix = String(line.prefix(4))
|
||||
// Check if it's a final line (code + space, not code + dash)
|
||||
// Format: "XXX " where X is a digit
|
||||
if prefix.count == 4,
|
||||
prefix[prefix.index(prefix.startIndex, offsetBy: 0)].isNumber,
|
||||
prefix[prefix.index(prefix.startIndex, offsetBy: 1)].isNumber,
|
||||
prefix[prefix.index(prefix.startIndex, offsetBy: 2)].isNumber,
|
||||
prefix[prefix.index(prefix.startIndex, offsetBy: 3)] == " " {
|
||||
isComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: don't loop forever
|
||||
if fullResponse.count > 100000 {
|
||||
throw EmailServiceError.connectionFailed("Response too large")
|
||||
}
|
||||
}
|
||||
|
||||
return fullResponse
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatEmailDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,16 @@ class SettingsService {
|
||||
// In-memory cache of DB settings for fast reads
|
||||
private var cache: [String: String] = [:]
|
||||
|
||||
// Keychain keys (secrets only)
|
||||
// Encrypted database keys (for API keys)
|
||||
private enum EncryptedKeys {
|
||||
static let openrouterAPIKey = "openrouterAPIKey"
|
||||
static let anthropicAPIKey = "anthropicAPIKey"
|
||||
static let openaiAPIKey = "openaiAPIKey"
|
||||
static let googleAPIKey = "googleAPIKey"
|
||||
static let googleSearchEngineID = "googleSearchEngineID"
|
||||
}
|
||||
|
||||
// Old keychain keys (for migration only)
|
||||
private enum KeychainKeys {
|
||||
static let openrouterAPIKey = "com.oai.apikey.openrouter"
|
||||
static let anthropicAPIKey = "com.oai.apikey.anthropic"
|
||||
@@ -32,6 +41,9 @@ class SettingsService {
|
||||
|
||||
// Migrate from UserDefaults on first launch
|
||||
migrateFromUserDefaultsIfNeeded()
|
||||
|
||||
// Migrate API keys from Keychain to encrypted database
|
||||
migrateFromKeychainIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Provider Settings
|
||||
@@ -102,6 +114,20 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
var customPromptMode: Settings.CustomPromptMode {
|
||||
get {
|
||||
if let rawValue = cache["customPromptMode"],
|
||||
let mode = Settings.CustomPromptMode(rawValue: rawValue) {
|
||||
return mode
|
||||
}
|
||||
return .append // Default to append mode
|
||||
}
|
||||
set {
|
||||
cache["customPromptMode"] = newValue.rawValue
|
||||
DatabaseService.shared.setSetting(key: "customPromptMode", value: newValue.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Feature Settings
|
||||
|
||||
var onlineMode: Bool {
|
||||
@@ -157,6 +183,26 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar Settings
|
||||
|
||||
/// Toolbar icon size — default 16 (minimum)
|
||||
var toolbarIconSize: Double {
|
||||
get { cache["toolbarIconSize"].flatMap(Double.init) ?? 16.0 }
|
||||
set {
|
||||
cache["toolbarIconSize"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "toolbarIconSize", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// Show labels on toolbar icons — default false
|
||||
var showToolbarLabels: Bool {
|
||||
get { cache["showToolbarLabels"] == "true" }
|
||||
set {
|
||||
cache["showToolbarLabels"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "showToolbarLabels", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MCP Permissions
|
||||
|
||||
var mcpCanWriteFiles: Bool {
|
||||
@@ -262,63 +308,392 @@ class SettingsService {
|
||||
cache["ollamaBaseURL"] != nil && !(cache["ollamaBaseURL"]!.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - API Keys (Keychain)
|
||||
// MARK: - API Keys (Encrypted Database)
|
||||
|
||||
var openrouterAPIKey: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.openrouterAPIKey) }
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.openrouterAPIKey) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.openrouterAPIKey)
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.openrouterAPIKey, value: value)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.openrouterAPIKey)
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.openrouterAPIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var anthropicAPIKey: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.anthropicAPIKey) }
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.anthropicAPIKey) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.anthropicAPIKey)
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.anthropicAPIKey, value: value)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.anthropicAPIKey)
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.anthropicAPIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var openaiAPIKey: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.openaiAPIKey) }
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.openaiAPIKey) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.openaiAPIKey)
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.openaiAPIKey, value: value)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.openaiAPIKey)
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.openaiAPIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var googleAPIKey: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.googleAPIKey) }
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.googleAPIKey) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.googleAPIKey)
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.googleAPIKey, value: value)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.googleAPIKey)
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.googleAPIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var googleSearchEngineID: String? {
|
||||
get { getKeychainValue(for: KeychainKeys.googleSearchEngineID) }
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.googleSearchEngineID) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
setKeychainValue(value, for: KeychainKeys.googleSearchEngineID)
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.googleSearchEngineID, value: value)
|
||||
} else {
|
||||
deleteKeychainValue(for: KeychainKeys.googleSearchEngineID)
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.googleSearchEngineID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Git Sync Settings
|
||||
|
||||
var syncEnabled: Bool {
|
||||
get { cache["syncEnabled"] == "true" }
|
||||
set {
|
||||
cache["syncEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncRepoURL: String {
|
||||
get { cache["syncRepoURL"] ?? "" }
|
||||
set {
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty {
|
||||
cache.removeValue(forKey: "syncRepoURL")
|
||||
DatabaseService.shared.deleteSetting(key: "syncRepoURL")
|
||||
} else {
|
||||
cache["syncRepoURL"] = trimmed
|
||||
DatabaseService.shared.setSetting(key: "syncRepoURL", value: trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var syncLocalPath: String {
|
||||
get { cache["syncLocalPath"] ?? "~/Library/Application Support/oAI/sync" }
|
||||
set {
|
||||
cache["syncLocalPath"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "syncLocalPath", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var syncAuthMethod: String {
|
||||
get { cache["syncAuthMethod"] ?? "token" } // Default to access token
|
||||
set {
|
||||
cache["syncAuthMethod"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "syncAuthMethod", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypted sync credentials
|
||||
var syncUsername: String? {
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: "syncUsername") }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: "syncUsername", value: value)
|
||||
} else {
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: "syncUsername")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var syncPassword: String? {
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: "syncPassword") }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: "syncPassword", value: value)
|
||||
} else {
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: "syncPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var syncAccessToken: String? {
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: "syncAccessToken") }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: "syncAccessToken", value: value)
|
||||
} else {
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: "syncAccessToken")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoExport: Bool {
|
||||
get { cache["syncAutoExport"] == "true" }
|
||||
set {
|
||||
cache["syncAutoExport"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoExport", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoPull: Bool {
|
||||
get { cache["syncAutoPull"] == "true" }
|
||||
set {
|
||||
cache["syncAutoPull"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoPull", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncConfigured: Bool {
|
||||
guard !syncRepoURL.isEmpty else { return false }
|
||||
|
||||
switch syncAuthMethod {
|
||||
case "ssh":
|
||||
return true // SSH uses system keys
|
||||
case "password":
|
||||
return syncUsername != nil && syncPassword != nil
|
||||
case "token":
|
||||
return syncAccessToken != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auto-Sync Settings
|
||||
|
||||
var syncAutoSave: Bool {
|
||||
get { cache["syncAutoSave"] == "true" }
|
||||
set {
|
||||
cache["syncAutoSave"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSave", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveMinMessages: Int {
|
||||
get { cache["syncAutoSaveMinMessages"].flatMap(Int.init) ?? 5 }
|
||||
set {
|
||||
cache["syncAutoSaveMinMessages"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveMinMessages", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveOnModelSwitch: Bool {
|
||||
get { cache["syncAutoSaveOnModelSwitch"] == "true" }
|
||||
set {
|
||||
cache["syncAutoSaveOnModelSwitch"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveOnModelSwitch", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveOnAppQuit: Bool {
|
||||
get { cache["syncAutoSaveOnAppQuit"] == "true" }
|
||||
set {
|
||||
cache["syncAutoSaveOnAppQuit"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveOnAppQuit", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveOnIdle: Bool {
|
||||
get { cache["syncAutoSaveOnIdle"] == "true" }
|
||||
set {
|
||||
cache["syncAutoSaveOnIdle"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveOnIdle", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncAutoSaveIdleMinutes: Int {
|
||||
get { cache["syncAutoSaveIdleMinutes"].flatMap(Int.init) ?? 5 }
|
||||
set {
|
||||
cache["syncAutoSaveIdleMinutes"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "syncAutoSaveIdleMinutes", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var syncLastAutoSaveConversationId: String? {
|
||||
get { cache["syncLastAutoSaveConversationId"] }
|
||||
set {
|
||||
if let value = newValue {
|
||||
cache["syncLastAutoSaveConversationId"] = value
|
||||
DatabaseService.shared.setSetting(key: "syncLastAutoSaveConversationId", value: value)
|
||||
} else {
|
||||
cache.removeValue(forKey: "syncLastAutoSaveConversationId")
|
||||
DatabaseService.shared.deleteSetting(key: "syncLastAutoSaveConversationId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Email Handler Settings
|
||||
|
||||
var emailHandlerEnabled: Bool {
|
||||
get { cache["emailHandlerEnabled"] == "true" }
|
||||
set {
|
||||
cache["emailHandlerEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "emailHandlerEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var emailHandlerProvider: String {
|
||||
get { cache["emailHandlerProvider"] ?? "openrouter" }
|
||||
set {
|
||||
cache["emailHandlerProvider"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "emailHandlerProvider", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var emailHandlerModel: String {
|
||||
get { cache["emailHandlerModel"] ?? "" }
|
||||
set {
|
||||
cache["emailHandlerModel"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "emailHandlerModel", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var emailSubjectIdentifier: String {
|
||||
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
|
||||
set {
|
||||
cache["emailSubjectIdentifier"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "emailSubjectIdentifier", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var emailRateLimitEnabled: Bool {
|
||||
get { cache["emailRateLimitEnabled"] == "true" }
|
||||
set {
|
||||
cache["emailRateLimitEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "emailRateLimitEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var emailRateLimitPerHour: Int {
|
||||
get { cache["emailRateLimitPerHour"].flatMap(Int.init) ?? 10 }
|
||||
set {
|
||||
cache["emailRateLimitPerHour"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "emailRateLimitPerHour", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var emailMaxTokens: Int {
|
||||
get { cache["emailMaxTokens"].flatMap(Int.init) ?? 2000 }
|
||||
set {
|
||||
cache["emailMaxTokens"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "emailMaxTokens", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var emailHandlerSystemPrompt: String? {
|
||||
get { cache["emailHandlerSystemPrompt"] }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
cache["emailHandlerSystemPrompt"] = value
|
||||
DatabaseService.shared.setSetting(key: "emailHandlerSystemPrompt", value: value)
|
||||
} else {
|
||||
cache.removeValue(forKey: "emailHandlerSystemPrompt")
|
||||
DatabaseService.shared.deleteSetting(key: "emailHandlerSystemPrompt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var emailOnlineMode: Bool {
|
||||
get { cache["emailOnlineMode"] == "true" }
|
||||
set {
|
||||
cache["emailOnlineMode"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "emailOnlineMode", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var emailHandlerConfigured: Bool {
|
||||
guard emailHandlerEnabled else { return false }
|
||||
guard !emailHandlerModel.isEmpty else { return false }
|
||||
// Check if email server is configured
|
||||
guard emailServerConfigured else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Email Server Settings
|
||||
|
||||
var emailImapHost: String? {
|
||||
get { cache["emailImapHost"] }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
cache["emailImapHost"] = value
|
||||
DatabaseService.shared.setSetting(key: "emailImapHost", value: value)
|
||||
} else {
|
||||
cache.removeValue(forKey: "emailImapHost")
|
||||
DatabaseService.shared.deleteSetting(key: "emailImapHost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var emailSmtpHost: String? {
|
||||
get { cache["emailSmtpHost"] }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
cache["emailSmtpHost"] = value
|
||||
DatabaseService.shared.setSetting(key: "emailSmtpHost", value: value)
|
||||
} else {
|
||||
cache.removeValue(forKey: "emailSmtpHost")
|
||||
DatabaseService.shared.deleteSetting(key: "emailSmtpHost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var emailUsername: String? {
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: "emailUsername") }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: "emailUsername", value: value)
|
||||
} else {
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: "emailUsername")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var emailPassword: String? {
|
||||
get { try? DatabaseService.shared.getEncryptedSetting(key: "emailPassword") }
|
||||
set {
|
||||
if let value = newValue, !value.isEmpty {
|
||||
try? DatabaseService.shared.setEncryptedSetting(key: "emailPassword", value: value)
|
||||
} else {
|
||||
DatabaseService.shared.deleteEncryptedSetting(key: "emailPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var emailImapPort: Int {
|
||||
get { cache["emailImapPort"].flatMap(Int.init) ?? 993 }
|
||||
set {
|
||||
cache["emailImapPort"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "emailImapPort", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var emailSmtpPort: Int {
|
||||
get { cache["emailSmtpPort"].flatMap(Int.init) ?? 587 }
|
||||
set {
|
||||
cache["emailSmtpPort"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "emailSmtpPort", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var emailServerConfigured: Bool {
|
||||
guard let imapHost = emailImapHost, !imapHost.isEmpty else { return false }
|
||||
guard let smtpHost = emailSmtpHost, !smtpHost.isEmpty else { return false }
|
||||
guard let username = emailUsername, !username.isEmpty else { return false }
|
||||
guard let password = emailPassword, !password.isEmpty else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - UserDefaults Migration
|
||||
|
||||
private func migrateFromUserDefaultsIfNeeded() {
|
||||
@@ -367,7 +742,47 @@ class SettingsService {
|
||||
DatabaseService.shared.setSetting(key: "_migrated", value: "true")
|
||||
}
|
||||
|
||||
// MARK: - Keychain Helpers
|
||||
// MARK: - Keychain Migration
|
||||
|
||||
private func migrateFromKeychainIfNeeded() {
|
||||
// Skip if already migrated
|
||||
guard cache["_keychain_migrated"] == nil else { return }
|
||||
|
||||
Log.settings.info("Migrating API keys from Keychain to encrypted database...")
|
||||
|
||||
let keysToMigrate: [(keychainKey: String, encryptedKey: String)] = [
|
||||
(KeychainKeys.openrouterAPIKey, EncryptedKeys.openrouterAPIKey),
|
||||
(KeychainKeys.anthropicAPIKey, EncryptedKeys.anthropicAPIKey),
|
||||
(KeychainKeys.openaiAPIKey, EncryptedKeys.openaiAPIKey),
|
||||
(KeychainKeys.googleAPIKey, EncryptedKeys.googleAPIKey),
|
||||
(KeychainKeys.googleSearchEngineID, EncryptedKeys.googleSearchEngineID),
|
||||
]
|
||||
|
||||
var migratedCount = 0
|
||||
for (keychainKey, encryptedKey) in keysToMigrate {
|
||||
// Read from keychain
|
||||
if let value = getKeychainValue(for: keychainKey), !value.isEmpty {
|
||||
// Write to encrypted database
|
||||
do {
|
||||
try DatabaseService.shared.setEncryptedSetting(key: encryptedKey, value: value)
|
||||
// Delete from keychain after successful migration
|
||||
deleteKeychainValue(for: keychainKey)
|
||||
migratedCount += 1
|
||||
Log.settings.info("Migrated \(encryptedKey) from Keychain to encrypted database")
|
||||
} catch {
|
||||
Log.settings.error("Failed to migrate \(encryptedKey): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.settings.info("Keychain migration complete: \(migratedCount) keys migrated")
|
||||
|
||||
// Mark migration complete
|
||||
cache["_keychain_migrated"] = "true"
|
||||
DatabaseService.shared.setSetting(key: "_keychain_migrated", value: "true")
|
||||
}
|
||||
|
||||
// MARK: - Keychain Helpers (for migration only)
|
||||
|
||||
private func getKeychainValue(for key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
|
||||
131
oAI/Services/ThinkingVerbs.swift
Normal file
131
oAI/Services/ThinkingVerbs.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// ThinkingVerbs.swift
|
||||
// oAI
|
||||
//
|
||||
// Fun random verbs for AI thinking/processing states
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ThinkingVerbs {
|
||||
/// Get a random thinking verb with ellipsis
|
||||
static func random() -> String {
|
||||
verbs.randomElement()! + "..."
|
||||
}
|
||||
|
||||
/// Collection of fun thinking verbs and phrases
|
||||
private static let verbs = [
|
||||
// Classic thinking
|
||||
"Thinking",
|
||||
"Pondering",
|
||||
"Contemplating",
|
||||
"Deliberating",
|
||||
"Musing",
|
||||
"Reflecting",
|
||||
"Meditating",
|
||||
|
||||
// Fancy/sophisticated
|
||||
"Cogitating",
|
||||
"Ruminating",
|
||||
"Cerebrating",
|
||||
"Ratiocinating",
|
||||
"Percolating",
|
||||
|
||||
// Technical/AI themed
|
||||
"Computing",
|
||||
"Processing",
|
||||
"Analyzing",
|
||||
"Synthesizing",
|
||||
"Calculating",
|
||||
"Inferring",
|
||||
"Deducing",
|
||||
"Compiling thoughts",
|
||||
"Running algorithms",
|
||||
"Crunching data",
|
||||
"Parsing neurons",
|
||||
|
||||
// Creative/playful
|
||||
"Daydreaming",
|
||||
"Brainstorming",
|
||||
"Mind-melding",
|
||||
"Noodling",
|
||||
"Brewing ideas",
|
||||
"Cooking up thoughts",
|
||||
"Marinating",
|
||||
"Percolating wisdom",
|
||||
"Spinning neurons",
|
||||
"Warming up the ol' noggin",
|
||||
|
||||
// Mystical/fun
|
||||
"Channeling wisdom",
|
||||
"Consulting the oracle",
|
||||
"Reading the tea leaves",
|
||||
"Summoning knowledge",
|
||||
"Conjuring responses",
|
||||
"Casting neural nets",
|
||||
"Divining answers",
|
||||
"Communing with silicon",
|
||||
|
||||
// Quirky/silly
|
||||
"Doing the thing",
|
||||
"Making magic",
|
||||
"Activating brain cells",
|
||||
"Flexing neurons",
|
||||
"Warming up transistors",
|
||||
"Revving up synapses",
|
||||
"Tickling the cortex",
|
||||
"Waking up the hamsters",
|
||||
"Consulting the void",
|
||||
"Asking the magic 8-ball",
|
||||
|
||||
// Self-aware/meta
|
||||
"Pretending to think",
|
||||
"Looking busy",
|
||||
"Stalling for time",
|
||||
"Counting sheep",
|
||||
"Twiddling thumbs",
|
||||
"Organizing thoughts",
|
||||
"Finding the right words",
|
||||
|
||||
// Speed variations
|
||||
"Thinking really hard",
|
||||
"Quick-thinking",
|
||||
"Deep thinking",
|
||||
"Speed-thinking",
|
||||
"Hyper-thinking",
|
||||
|
||||
// Action-oriented
|
||||
"Crafting",
|
||||
"Weaving words",
|
||||
"Assembling thoughts",
|
||||
"Constructing responses",
|
||||
"Formulating ideas",
|
||||
"Orchestrating neurons",
|
||||
"Choreographing bits",
|
||||
|
||||
// Whimsical
|
||||
"Having an epiphany",
|
||||
"Connecting the dots",
|
||||
"Following the thread",
|
||||
"Chasing thoughts",
|
||||
"Herding ideas",
|
||||
"Untangling neurons",
|
||||
|
||||
// Time-based
|
||||
"Taking a moment",
|
||||
"Pausing thoughtfully",
|
||||
"Taking a beat",
|
||||
"Gathering thoughts",
|
||||
"Catching my breath",
|
||||
"Taking five",
|
||||
|
||||
// Just plain weird
|
||||
"Beep boop computing",
|
||||
"Engaging brain mode",
|
||||
"Activating smartness",
|
||||
"Downloading thoughts",
|
||||
"Buffering intelligence",
|
||||
"Loading brilliance",
|
||||
"Unfurling wisdom"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user