Lets the AI search Apple Mail, read a message, and save an attachment to disk (e.g. to hand off to paperless_upload_document) — e.g. "find the receipt from Elkjøp and add it to Paperless." Talks to Mail.app via AppleScript/Apple Events rather than parsing its private on-disk store, so no Full Disk Access or MIME parsing is needed; Mail's own attachment save handles all decoding. Every mail_* tool call requires approval (Deny/Allow Once/Allow for Session), mirroring the bash_execute and Personal Data gate pattern. Also fixes a pre-existing bug found while touching the adjacent tool-activation condition: paperlessEnabled was missing from it, so Paperless tools could fail to activate unless another integration was also active.
186 lines
7.5 KiB
Swift
186 lines
7.5 KiB
Swift
//
|
|
// AppleMailServiceTests.swift
|
|
// oAITests
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Confab
|
|
|
|
@Suite("AppleMailService descriptor decoding and formatting")
|
|
struct AppleMailServiceTests {
|
|
|
|
// MARK: - decodeStringList
|
|
|
|
private func makeListDescriptor(_ items: [String]) -> NSAppleEventDescriptor {
|
|
let list = NSAppleEventDescriptor.list()
|
|
for (index, item) in items.enumerated() {
|
|
list.insert(NSAppleEventDescriptor(string: item), at: index + 1)
|
|
}
|
|
return list
|
|
}
|
|
|
|
@Test("decodeStringList decodes a flat list of strings")
|
|
func decodesFlatList() {
|
|
let descriptor = makeListDescriptor(["a", "b", "c"])
|
|
#expect(AppleMailService.decodeStringList(descriptor) == ["a", "b", "c"])
|
|
}
|
|
|
|
@Test("decodeStringList returns empty array for an empty list")
|
|
func decodesEmptyList() {
|
|
let descriptor = makeListDescriptor([])
|
|
#expect(AppleMailService.decodeStringList(descriptor) == [])
|
|
}
|
|
|
|
@Test("decodeStringList falls back to a single-element array for a bare string descriptor")
|
|
func decodesBareString() {
|
|
let descriptor = NSAppleEventDescriptor(string: "hello")
|
|
#expect(AppleMailService.decodeStringList(descriptor) == ["hello"])
|
|
}
|
|
|
|
// MARK: - parseSearchResults
|
|
|
|
@Test("parseSearchResults decodes a list of 7-field message records")
|
|
func parsesSearchResults() {
|
|
let record1 = makeListDescriptor(["msg-1", "Receipt from Elkjøp", "orders@elkjop.no", "Monday, 1 January 2026", "INBOX", "Personal", "1"])
|
|
let record2 = makeListDescriptor(["msg-2", "Another subject", "someone@example.com", "Tuesday, 2 January 2026", "INBOX", "Work", "0"])
|
|
let outer = NSAppleEventDescriptor.list()
|
|
outer.insert(record1, at: 1)
|
|
outer.insert(record2, at: 2)
|
|
|
|
let results = AppleMailService.parseSearchResults(from: outer)
|
|
#expect(results.count == 2)
|
|
#expect(results[0].messageId == "msg-1")
|
|
#expect(results[0].subject == "Receipt from Elkjøp")
|
|
#expect(results[0].from == "orders@elkjop.no")
|
|
#expect(results[0].mailbox == "INBOX")
|
|
#expect(results[0].account == "Personal")
|
|
#expect(results[0].attachmentCount == "1")
|
|
#expect(results[1].messageId == "msg-2")
|
|
#expect(results[1].attachmentCount == "0")
|
|
}
|
|
|
|
@Test("parseSearchResults returns empty array for an empty outer list")
|
|
func parsesEmptySearchResults() {
|
|
let outer = NSAppleEventDescriptor.list()
|
|
#expect(AppleMailService.parseSearchResults(from: outer) == [])
|
|
}
|
|
|
|
@Test("parseSearchResults skips malformed records with too few fields")
|
|
func skipsMalformedRecords() {
|
|
let goodRecord = makeListDescriptor(["msg-1", "Subject", "from@example.com", "date", "INBOX", "Account", "0"])
|
|
let badRecord = makeListDescriptor(["only", "three", "fields"])
|
|
let outer = NSAppleEventDescriptor.list()
|
|
outer.insert(badRecord, at: 1)
|
|
outer.insert(goodRecord, at: 2)
|
|
|
|
let results = AppleMailService.parseSearchResults(from: outer)
|
|
#expect(results.count == 1)
|
|
#expect(results[0].messageId == "msg-1")
|
|
}
|
|
|
|
// MARK: - mapAppleScriptError
|
|
|
|
@Test("mapAppleScriptError maps -1743 to an Automation-permission message")
|
|
func mapsNotAuthorizedError() {
|
|
let dict: NSDictionary = [
|
|
NSAppleScript.errorNumber: -1743,
|
|
NSAppleScript.errorMessage: "Not authorized"
|
|
]
|
|
let message = AppleMailService.mapAppleScriptError(dict)
|
|
#expect(message.contains("not authorized"))
|
|
#expect(message.contains("Automation"))
|
|
}
|
|
|
|
@Test("mapAppleScriptError maps -600 to a Mail-not-available message")
|
|
func mapsApplicationNotRunningError() {
|
|
let dict: NSDictionary = [
|
|
NSAppleScript.errorNumber: -600,
|
|
NSAppleScript.errorMessage: "Application isn't running"
|
|
]
|
|
let message = AppleMailService.mapAppleScriptError(dict)
|
|
#expect(message.contains("not available"))
|
|
}
|
|
|
|
@Test("mapAppleScriptError falls back to a generic formatted message for unknown error numbers")
|
|
func mapsGenericError() {
|
|
let dict: NSDictionary = [
|
|
NSAppleScript.errorNumber: -1234,
|
|
NSAppleScript.errorMessage: "Something odd happened"
|
|
]
|
|
let message = AppleMailService.mapAppleScriptError(dict)
|
|
#expect(message.contains("-1234"))
|
|
#expect(message.contains("Something odd happened"))
|
|
}
|
|
|
|
// MARK: - parseArgs
|
|
|
|
@Test("parseArgs decodes a JSON arguments string")
|
|
func parsesArgsJSON() {
|
|
let args = AppleMailService.parseArgs("{\"account\":\"work@example.com\",\"subject_contains\":\"Elkjøp\"}")
|
|
#expect(args["account"] as? String == "work@example.com")
|
|
#expect(args["subject_contains"] as? String == "Elkjøp")
|
|
}
|
|
|
|
@Test("parseArgs returns empty dictionary for invalid JSON")
|
|
func parsesInvalidArgsJSON() {
|
|
let args = AppleMailService.parseArgs("not json")
|
|
#expect(args.isEmpty)
|
|
}
|
|
|
|
// MARK: - hasSearchCriteria
|
|
|
|
@Test("hasSearchCriteria is false when every criterion is nil or empty")
|
|
func rejectsUnscopedSearch() {
|
|
#expect(AppleMailService.hasSearchCriteria(subjectContains: nil, fromContains: "", bodyContains: nil, since: nil, before: "") == false)
|
|
}
|
|
|
|
@Test("hasSearchCriteria is true when any single criterion is non-empty")
|
|
func acceptsScopedSearch() {
|
|
#expect(AppleMailService.hasSearchCriteria(subjectContains: "Elkjøp", fromContains: nil, bodyContains: nil, since: nil, before: nil) == true)
|
|
#expect(AppleMailService.hasSearchCriteria(subjectContains: nil, fromContains: nil, bodyContains: nil, since: "2026-01-01", before: nil) == true)
|
|
}
|
|
|
|
// MARK: - approvalSummary
|
|
|
|
@Test("approvalSummary describes mail_list_accounts")
|
|
func summarizesListAccounts() {
|
|
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_list_accounts", arguments: "{}")
|
|
#expect(summary == "List your Mail.app accounts")
|
|
}
|
|
|
|
@Test("approvalSummary includes account and subject scope for mail_search")
|
|
func summarizesSearch() {
|
|
let summary = AppleMailService.shared.approvalSummary(
|
|
forTool: "mail_search",
|
|
arguments: "{\"account\":\"work@example.com\",\"subject_contains\":\"Elkjøp\"}"
|
|
)
|
|
#expect(summary.contains("work@example.com"))
|
|
#expect(summary.contains("Elkjøp"))
|
|
}
|
|
|
|
@Test("approvalSummary for mail_search with no scope still returns a sensible label")
|
|
func summarizesUnscopedSearch() {
|
|
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_search", arguments: "{}")
|
|
#expect(summary == "Search Mail")
|
|
}
|
|
|
|
@Test("approvalSummary describes mail_save_attachment with name and destination")
|
|
func summarizesSaveAttachment() {
|
|
let summary = AppleMailService.shared.approvalSummary(
|
|
forTool: "mail_save_attachment",
|
|
arguments: "{\"attachment_name\":\"receipt.pdf\",\"dest_path\":\"/tmp/receipt.pdf\"}"
|
|
)
|
|
#expect(summary.contains("receipt.pdf"))
|
|
#expect(summary.contains("/tmp/receipt.pdf"))
|
|
}
|
|
|
|
@Test("approvalSummary falls back to a generic label for an unknown tool name")
|
|
func summarizesUnknownTool() {
|
|
let summary = AppleMailService.shared.approvalSummary(forTool: "mail_bogus", arguments: "{}")
|
|
#expect(summary == "Perform action: mail_bogus")
|
|
}
|
|
}
|