///| Persist the last handled UID per account/mailbox so a reconnect does not
/// rescan mail that was already processed.
///
/// The cursor used to live only in memory, and the `UIDNEXT` reported by the
/// server was trusted to recover it. Servers that answer `UIDNEXT 0` (and the
/// first run after an upgrade, where no cursor exists yet) therefore replayed
/// the whole mailbox and ran every matching rule command again. The cursor file
/// lives next to `mails.toml` as `...cursor` with mode `0600`.
///|
/// Replace anything that is not safe in a file name, so a mailbox such as
/// `Sent Items/2026` still maps to a flat, usable path.
fn cursor_safe_name(name : String) -> String {
let out = StringBuilder()
for c in name {
if (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '_' ||
c == '.' {
out.write_char(c)
} else {
out.write_char('_')
}
}
out.to_string()
}
///|
/// Path of the cursor file for one account and mailbox.
pub fn cursor_path(
config_dir : String,
account_name : String,
mailbox : String,
) -> String {
config_dir +
"/." +
cursor_safe_name(account_name) +
"." +
cursor_safe_name(mailbox) +
".cursor"
}
///|
/// Load the saved cursor as `(uid_validity, last_uid)`.
///
/// `(0, 0)` means "nothing usable on disk": still no cursor, an unreadable
/// file, or a file from a mailbox with a different UID space.
pub async fn load_cursor(
config_dir : String,
account_name : String,
mailbox : String,
) -> (Int32, Int32) {
let data = @fs.read_file(cursor_path(config_dir, account_name, mailbox)) catch {
_ => return (0, 0)
}
let text = data.text() catch { _ => return (0, 0) }
let mut uid_validity = 0
let mut last_uid = 0
for raw in text.split("\n") {
let line = trim(raw)
if line.has_prefix("uidvalidity=") {
uid_validity = @string.parse_int(
line["uidvalidity=".length():].to_owned(),
) catch {
_ => return (0, 0)
}
} else if line.has_prefix("last_uid=") {
last_uid = @string.parse_int(line["last_uid=".length():].to_owned()) catch {
_ => return (0, 0)
}
}
}
if uid_validity < 0 || last_uid < 0 {
return (0, 0)
}
(uid_validity, last_uid)
}
///|
/// Save the cursor atomically through a temp file plus rename, so a crash in
/// the middle never leaves a half-written cursor behind.
pub async fn save_cursor(
config_dir : String,
account_name : String,
mailbox : String,
uid_validity : Int32,
last_uid : Int32,
) -> Unit {
let path = cursor_path(config_dir, account_name, mailbox)
let tmp = path + ".tmp"
let body = "uidvalidity=" +
uid_validity.to_string() +
"\nlast_uid=" +
last_uid.to_string() +
"\n"
@fs.write_file(tmp, body, create_mode=CreateOrTruncate, permission=0o600) catch {
err => {
log_warn("cannot save the cursor " + tmp + ": " + error_message(err))
return
}
}
@fs.chmod(tmp, 0o600) catch {
_ => ()
}
@fs.rename(tmp, path, replace=true) catch {
err =>
log_warn("cannot save the cursor " + path + ": " + error_message(err))
}
}