///| The watch loop: one connection per mailbox, IDLE when available and
/// polling when it is not.
///|
pub struct Runtime {
account : Account
rules : Array[Rule]
config_dir : String
key : String
once : Bool
failed : Ref[Bool]
}
///|
fn merge_rules(global : Array[Rule], account_rule : Array[Rule]) -> Array[Rule] {
let out : Array[Rule] = []
for rule in global {
out.push(rule)
}
for rule in account_rule {
out.push(rule)
}
out
}
///|
/// Handle one fetched message: parse, match every rule, run their commands.
async fn handle_message(
runtime : Runtime,
mailbox : String,
fetched : FetchedMessage,
) -> Unit {
let message = build_message(
mailbox,
fetched.uid,
fetched.size,
fetched.header,
fetched.body,
)
if is_blank(message.subject) && is_blank(message.body) {
log_debug("uid " + fetched.uid + " has no text to match")
}
let hits = match_rules(runtime.rules, runtime.account.name, mailbox, message)
if hits.length() == 0 {
log_debug(
"uid " +
fetched.uid +
" in " +
mailbox +
" matched no rule: " +
truncate_chars(message.subject, 120),
)
return
}
for hit in hits {
ignore(run_action(runtime.account.name, mailbox, message, hit))
}
}
///|
/// Fetch and handle every message newer than `last_uid`.
async fn sync_mailbox(
runtime : Runtime,
client : ImapClient,
mailbox : String,
last_uid : Int32,
) -> Int32 {
let from_uid = if last_uid < 0 { 1 } else { last_uid + 1 }
let uids = client.search_uids(from_uid)
log_debug(
"search after uid " +
from_uid.to_string() +
" returned " +
uids.length().to_string() +
" entries",
)
let mut highest = last_uid
// The search may return the highest existing UID for a `n:*` range; sort
// numerically so commands run oldest first.
let ordered : Array[Int32] = []
for raw in uids {
let value = @string.parse_int(raw) catch { _ => -1 }
if value > last_uid {
ordered.push(value)
}
}
let mut index = 0
while index < ordered.length() {
let mut best = index
let mut scan = index + 1
while scan < ordered.length() {
if ordered[scan] < ordered[best] {
best = scan
}
scan = scan + 1
}
if best != index {
let tmp = ordered[index]
ordered[index] = ordered[best]
ordered[best] = tmp
}
index = index + 1
}
log_debug("processing " + ordered.length().to_string() + " new messages")
for uid in ordered {
let uid_text = uid.to_string()
let fetched = client.fetch_message(uid_text, runtime.account.max_body_bytes) catch {
err => {
log_warn("cannot fetch uid " + uid_text + ": " + err.to_string())
None
}
}
match fetched {
Some(message) =>
handle_message(runtime, mailbox, message) catch {
err => log_error("uid " + uid_text + " failed: " + err.to_string())
}
None => log_warn("uid " + uid_text + " is no longer available")
}
if uid > highest {
highest = uid
}
}
highest
}
///|
/// Run the session body, then always close the connection.
async fn watch_mailbox(runtime : Runtime, mailbox : String) -> Unit {
let account = runtime.account
let credential = credential_for(account, runtime.config_dir, runtime.key)
let client = ImapClient::connect_with(
account.host,
account.port,
account.use_tls,
account.tls_verify,
account.username,
credential,
)
let failure : Error? = (() => {
watch_connected(runtime, mailbox, client)
None
})() catch {
err => Some(err)
}
client.close() catch {
_ => ()
}
match failure {
Some(err) => raise err
None => ()
}
}
///|
async fn watch_connected(
runtime : Runtime,
mailbox : String,
client : ImapClient,
) -> Unit {
let account = runtime.account
let mut status = client.select_mailbox(mailbox)
// `mails sync` scans the whole mailbox even when startup_sync is off; a
// one-shot run that skipped everything would be useless.
let mut last_uid = if account.startup_sync || runtime.once {
0
} else if status.uid_next > 0 {
status.uid_next - 1
} else {
0
}
log_info(
"watching " +
account.name +
" / " +
mailbox +
" (uidvalidity " +
status.uid_validity.to_string() +
", next uid " +
status.uid_next.to_string() +
")",
)
last_uid = sync_mailbox(runtime, client, mailbox, last_uid)
if runtime.once {
log_info("sync of " + mailbox + " finished")
return
}
let mut polling = false
while true {
let changed = if polling {
@async.sleep(poll_interval_ms)
true
} else {
client.idle(account.idle_timeout_ms) catch {
IdleUnsupported => {
polling = true
log_info(
"server does not support IDLE; polling " +
mailbox +
" every " +
format_duration(poll_interval_ms),
)
true
}
err => raise err
}
}
let next_status = client.select_mailbox(mailbox)
if next_status.uid_validity != status.uid_validity {
log_warn(
"UIDVALIDITY changed for " + mailbox + "; rescanning the mailbox",
)
last_uid = 0
}
status = next_status
if changed || status.uid_next - 1 > last_uid {
last_uid = sync_mailbox(runtime, client, mailbox, last_uid)
}
}
}
///|
let poll_interval_ms : Int32 = 60_000
///|
/// Reconnect forever, with capped exponential backoff.
async fn mailbox_loop(runtime : Runtime, mailbox : String) -> Unit {
let base_backoff = if runtime.account.reconnect_ms > 0 {
runtime.account.reconnect_ms
} else {
30_000
}
let mut backoff = base_backoff
while true {
let started = @async.now()
let failure : Error? = (() => {
watch_mailbox(runtime, mailbox)
None
})() catch {
err => Some(err)
}
match failure {
None => return
Some(err) => {
log_error(
"[" +
runtime.account.name +
" / " +
mailbox +
"] " +
error_message(err),
)
if runtime.once {
runtime.failed.val = true
return
}
// A session that stayed up for a while means the failure is new, not
// a tight reconnect loop: start the backoff from the configured delay.
if @async.now() - started > 60_000L {
backoff = base_backoff
}
log_info("reconnecting in " + format_duration(backoff))
@async.sleep(backoff)
backoff = if backoff * 2 > 300_000 { 300_000 } else { backoff * 2 }
}
}
}
}
///|
/// Start watching every selected account and mailbox, or sync once.
pub async fn run_watch(
config : Config,
accounts : Array[Account],
key : String,
once : Bool,
) -> Unit {
let failed : Ref[Bool] = Ref(false)
@async.with_task_group(group => {
for account in accounts {
// Fail fast on a missing password instead of reconnecting forever.
if account.auth is PasswordLogin {
if account.password == "" {
raise MailsError(
"account '" +
account.name +
"' has no password; run `mails encrypt --account " +
account.name +
" --write`",
)
}
ignore(resolve_secret(key, account.password))
}
let runtime : Runtime = {
account,
rules: merge_rules(config.rules, account.rules),
config_dir: config.dir,
key,
once,
failed,
}
for mailbox in account.mailboxes {
group.spawn_bg(() => mailbox_loop(runtime, mailbox))
}
}
})
if once && failed.val {
raise MailsError("one or more mailboxes failed to sync")
}
}