Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Documentation/Daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ Each key is a **server ID** — a short name you'll use with `--server` in the C
|-------|------|---------|-------------|
| `httpPort` | `Int` | — | Enable HTTP+SSE transport on this port (in addition to Bonjour) |

## IMAP Connections

Per server the daemon keeps:

- **One primary connection** for listing, fetching, moving, flagging, drafts and everything else. It runs one request at a time: IMAP keeps the selected mailbox per connection, so a request selects its mailbox and finishes its commands before the next request may select another.
- **Up to two extra connections** for searches, counts and raw `.eml` downloads (`post search`, `post fetch --eml`). Each is leased to one request at a time, so these can run while the primary connection is busy. They open on first use; one-at-a-time use only ever opens the first. If the server refuses an extra connection, the request runs on the primary connection instead.
- **IDLE connections** for watched mailboxes (see below).

Concurrent requests are safe: each returns the same result it would return on its own.

## IMAP IDLE

IDLE is optional and can be enabled per server. When enabled, the daemon maintains a dedicated IMAP connection that listens for real-time mailbox changes — no polling needed.
Expand Down
33 changes: 33 additions & 0 deletions Sources/PostServer/AsyncSerialLock.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

/// A lock for async code: one holder at a time, waiters resume in arrival order.
///
/// IMAP keeps the selected mailbox per connection, and every Post operation selects
/// a mailbox and then awaits further commands. Actors don't serialize work across
/// those suspension points, so without this lock a concurrent request's SELECT can
/// land in between and the commands run in the wrong mailbox. The lock is held for
/// the whole operation, not per command.
actor AsyncSerialLock {
private var isLocked = false
private var waiters: [CheckedContinuation<Void, Never>] = []

/// Number of callers waiting for the lock; for tests.
var waiterCount: Int { waiters.count }

func lock() async {
guard isLocked else {
isLocked = true
return
}
await withCheckedContinuation { waiters.append($0) }
}

func unlock() {
if waiters.isEmpty {
isLocked = false
} else {
// Hand the lock straight to the next waiter; it stays held.
waiters.removeFirst().resume()
}
}
}
39 changes: 39 additions & 0 deletions Sources/PostServer/ConnectionSlotPool.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

/// Leases a fixed number of slots, each to one holder at a time.
///
/// Each slot stands for one of an account's extra IMAP connections. A search or raw
/// download leases a slot for its whole duration, so no two operations ever share an
/// extra connection and its selected mailbox. The lowest free slot is handed out first,
/// so one-at-a-time use keeps a single extra connection open.
actor ConnectionSlotPool {
let size: Int
private var free: Set<Int>
private var waiters: [CheckedContinuation<Int, Never>] = []

init(size: Int) {
precondition(size > 0, "A connection slot pool needs at least one slot")
self.size = size
self.free = Set(0..<size)
}

/// Number of callers waiting for a slot; for tests.
var waiterCount: Int { waiters.count }

func acquire() async -> Int {
if let slot = free.min() {
free.remove(slot)
return slot
}
return await withCheckedContinuation { waiters.append($0) }
}

func release(_ slot: Int) {
if waiters.isEmpty {
free.insert(slot)
} else {
// Hand the slot straight to the next waiter.
waiters.removeFirst().resume(returning: slot)
}
}
}
5 changes: 5 additions & 0 deletions Sources/PostServer/IMAPConnectionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import Logging
import SwiftMail

public actor IMAPConnectionManager {
/// Extra connections per account for searches and raw downloads, on top of the
/// primary connection and any IDLE connections. Kept small: servers cap the
/// connections per user (Dovecot 10 per IP, Gmail 15).
public static let extraConnectionsPerServer = 2

private let configuration: PostConfiguration
private var connections: [String: IMAPServer] = [:]
/// In-flight connection tasks so concurrent callers share a single attempt.
Expand Down
59 changes: 59 additions & 0 deletions Sources/PostServer/MailboxCommandRunning.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
import NIOIMAPCore
import SwiftMail

// NIOIMAPCore also declares IMAPServer, MessageIdentifier and MessageIdentifierSet, so the
// SwiftMail types are qualified here.

/// The commands searches and raw downloads need. Both the shared primary connection
/// (`IMAPServer`) and an extra connection (`IMAPNamedConnection`) provide them, so the
/// same code runs on an extra connection or, as a fallback, on the primary one.
protocol MailboxCommandRunning: Actor {
func selectMailbox(_ mailboxName: String) async throws -> SwiftMail.Mailbox.Selection

func extendedSearch<T: SwiftMail.MessageIdentifier>(
identifierSet: SwiftMail.MessageIdentifierSet<T>?,
criteria: [SwiftMail.SearchCriteria],
sortCriteria: [SortCriterion],
sortCharset: String,
calendar: Calendar,
partialRange: PartialRange?
) async throws -> SwiftMail.ExtendedSearchResult<T>

func fetchMessageInfosBulk<T: SwiftMail.MessageIdentifier>(
using identifierSet: SwiftMail.MessageIdentifierSet<T>,
options: SwiftMail.FetchMessageInfoOptions,
headerFields: [String]?
) async throws -> [SwiftMail.MessageInfo]

func fetchRawMessage<T: SwiftMail.MessageIdentifier>(identifier: T) async throws -> Data
}

extension SwiftMail.IMAPServer: MailboxCommandRunning {}
extension SwiftMail.IMAPNamedConnection: MailboxCommandRunning {}

extension MailboxCommandRunning {
/// Unsorted extended search; named apart from `extendedSearch` so calls on a concrete
/// `IMAPServer` stay unambiguous.
func searchUIDs<T: SwiftMail.MessageIdentifier>(
identifierSet: SwiftMail.MessageIdentifierSet<T>?,
criteria: [SwiftMail.SearchCriteria],
partialRange: PartialRange?
) async throws -> SwiftMail.ExtendedSearchResult<T> {
try await extendedSearch(
identifierSet: identifierSet,
criteria: criteria,
sortCriteria: [],
sortCharset: "UTF-8",
calendar: Calendar(identifier: .gregorian),
partialRange: partialRange
)
}

/// Envelope, flags and dates only, no message bodies.
func fetchHeaderInfos<T: SwiftMail.MessageIdentifier>(
using identifierSet: SwiftMail.MessageIdentifierSet<T>
) async throws -> [SwiftMail.MessageInfo] {
try await fetchMessageInfosBulk(using: identifierSet, options: .default, headerFields: nil)
}
}
Loading
Loading