add models module: shared types for accounts, messages, threads, sync

fix Package.swift: remove NIOIMAPCore product reference (only NIOIMAP is exported by swift-nio-imap)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:11:24 +01:00
parent 9c3d02ae45
commit f37b287f5e
12 changed files with 198 additions and 4 deletions

View File

@@ -32,7 +32,6 @@ let package = Package(
name: "IMAPClient",
dependencies: [
"Models",
.product(name: "NIOIMAPCore", package: "swift-nio-imap"),
.product(name: "NIOIMAP", package: "swift-nio-imap"),
.product(name: "NIOSSL", package: "swift-nio-ssl"),
]
@@ -47,7 +46,6 @@ let package = Package(
name: "IMAPClientTests",
dependencies: [
"IMAPClient",
.product(name: "NIOIMAPCore", package: "swift-nio-imap"),
]
),
.testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine", "IMAPClient", "MailStore"]),

View File

@@ -0,0 +1,15 @@
public struct AccountConfig: Sendable, Codable, Equatable {
public var id: String
public var name: String
public var email: String
public var imapHost: String
public var imapPort: Int
public init(id: String, name: String, email: String, imapHost: String, imapPort: Int) {
self.id = id
self.name = name
self.email = email
self.imapHost = imapHost
self.imapPort = imapPort
}
}

View File

@@ -0,0 +1,9 @@
public struct Credentials: Sendable {
public var username: String
public var password: String
public init(username: String, password: String) {
self.username = username
self.password = password
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
public struct EmailAddress: Sendable, Codable, Equatable, Hashable {
public var name: String?
public var address: String
public init(name: String? = nil, address: String) {
self.name = name
self.address = address
}
public var displayName: String {
name ?? address
}
/// Parses "Alice <alice@example.com>" or bare "alice@example.com"
public static func parse(_ raw: String) -> EmailAddress {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard let openAngle = trimmed.lastIndex(of: "<"),
let closeAngle = trimmed.lastIndex(of: ">"),
openAngle < closeAngle
else {
return EmailAddress(address: trimmed)
}
let addr = String(trimmed[trimmed.index(after: openAngle)..<closeAngle])
let namepart = String(trimmed[..<openAngle]).trimmingCharacters(in: .whitespaces)
return EmailAddress(
name: namepart.isEmpty ? nil : namepart.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
address: addr
)
}
}

View File

@@ -0,0 +1,27 @@
public struct MailboxInfo: Sendable, Identifiable, Equatable {
public var id: String
public var accountId: String
public var name: String
public var unreadCount: Int
public var totalCount: Int
public init(id: String, accountId: String, name: String, unreadCount: Int, totalCount: Int) {
self.id = id
self.accountId = accountId
self.name = name
self.unreadCount = unreadCount
self.totalCount = totalCount
}
public var systemImage: String {
switch name.lowercased() {
case "inbox": "tray"
case "sent", "sent messages": "paperplane"
case "drafts": "doc"
case "trash", "deleted messages": "trash"
case "archive", "all mail": "archivebox"
case "junk", "spam": "xmark.bin"
default: "folder"
}
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
public struct MessageSummary: Sendable, Identifiable, Equatable {
public var id: String
public var messageId: String?
public var threadId: String?
public var from: EmailAddress?
public var to: [EmailAddress]
public var cc: [EmailAddress]
public var subject: String?
public var date: Date
public var snippet: String?
public var bodyText: String?
public var bodyHtml: String?
public var isRead: Bool
public var isFlagged: Bool
public var hasAttachments: Bool
public init(
id: String, messageId: String?, threadId: String?,
from: EmailAddress?, to: [EmailAddress], cc: [EmailAddress],
subject: String?, date: Date, snippet: String?,
bodyText: String?, bodyHtml: String?,
isRead: Bool, isFlagged: Bool, hasAttachments: Bool
) {
self.id = id
self.messageId = messageId
self.threadId = threadId
self.from = from
self.to = to
self.cc = cc
self.subject = subject
self.date = date
self.snippet = snippet
self.bodyText = bodyText
self.bodyHtml = bodyHtml
self.isRead = isRead
self.isFlagged = isFlagged
self.hasAttachments = hasAttachments
}
}

View File

@@ -1 +0,0 @@
enum ModelsPlaceholder {}

View File

@@ -0,0 +1,9 @@
/// Note: spec defines syncFailed(Error), but Error is not Sendable.
/// Using String instead for Swift 6 strict concurrency compliance.
public enum SyncEvent: Sendable {
case syncStarted
case newMessages(count: Int, mailbox: String)
case flagsChanged(messageIds: [String])
case syncCompleted
case syncFailed(String)
}

View File

@@ -0,0 +1,5 @@
public enum SyncState: Sendable, Equatable {
case idle
case syncing(mailbox: String?)
case error(String)
}

View File

@@ -0,0 +1,26 @@
import Foundation
public struct ThreadSummary: Sendable, Identifiable, Equatable {
public var id: String
public var accountId: String
public var subject: String?
public var lastDate: Date
public var messageCount: Int
public var unreadCount: Int
public var senders: String
public var snippet: String?
public init(
id: String, accountId: String, subject: String?, lastDate: Date,
messageCount: Int, unreadCount: Int, senders: String, snippet: String?
) {
self.id = id
self.accountId = accountId
self.subject = subject
self.lastDate = lastDate
self.messageCount = messageCount
self.unreadCount = unreadCount
self.senders = senders
self.snippet = snippet
}
}

View File

@@ -0,0 +1,34 @@
import Testing
@testable import Models
@Suite("EmailAddress")
struct EmailAddressTests {
@Test("parses name and address from angle-bracket format")
func parsesNameAndAddress() {
let addr = EmailAddress.parse("Alice <alice@example.com>")
#expect(addr.name == "Alice")
#expect(addr.address == "alice@example.com")
#expect(addr.displayName == "Alice")
}
@Test("parses bare email address")
func parsesBareAddress() {
let addr = EmailAddress.parse("bob@example.com")
#expect(addr.name == nil)
#expect(addr.address == "bob@example.com")
#expect(addr.displayName == "bob@example.com")
}
@Test("parses quoted name with angle brackets")
func parsesQuotedName() {
let addr = EmailAddress.parse("\"Bob Smith\" <bob@example.com>")
#expect(addr.name == "Bob Smith")
#expect(addr.address == "bob@example.com")
}
@Test("handles empty string gracefully")
func handlesEmpty() {
let addr = EmailAddress.parse("")
#expect(addr.address == "")
}
}

View File

@@ -1 +0,0 @@
import Testing