///|
/// Browsing a mailbox: listing folders, listing messages and reading one.
///
/// This is the read side of the CLI, the counterpart of `watch`. It uses the
/// same `ImapClient`, so password, OAuth2 and TLS behaviour stay identical; the
/// only new protocol pieces are `LIST` and a header-only `UID FETCH`.
///
/// Nothing here runs a rule or writes a cursor: `ls` and `read` are for a human
/// at a terminal (or a script), and must not change what `watch` will do next.
///|
pub struct Folder {
name : String
delimiter : String
attributes : Array[String]
}
///|
/// One row of `mails ls`: everything the envelope list shows, decoded.
pub struct Envelope {
uid : String
date : String
from : String
subject : String
size : Int32
seen : Bool
flagged : Bool
}
///|
/// Parse one `LIST` line:
///
/// ```text
/// * LIST (\HasNoChildren) "/" "INBOX"
/// * LIST (\Noselect) "/" "[Gmail]/All Mail"
/// ```
pub fn parse_list_line(line : String) -> Folder? {
let text = trim(line)
if !text.has_prefix("* LIST") {
return None
}
let rest = text[7:].to_owned()
let mut index = 0
let chars = rest.to_array()
// Attributes: a parenthesised list, possibly empty.
while index < chars.length() && chars[index] == ' ' {
index = index + 1
}
let attributes : Array[String] = []
if index < chars.length() && chars[index] == '(' {
index = index + 1
let current = StringBuilder()
while index < chars.length() && chars[index] != ')' {
if chars[index] == ' ' {
if current.to_string() != "" {
attributes.push(current.to_string())
current.reset()
}
} else {
current.write_char(chars[index])
}
index = index + 1
}
if current.to_string() != "" {
attributes.push(current.to_string())
}
index = index + 1
}
// Delimiter: a quoted string or NIL.
while index < chars.length() && chars[index] == ' ' {
index = index + 1
}
let mut delimiter = ""
if index < chars.length() && chars[index] == '"' {
index = index + 1
let value = StringBuilder()
while index < chars.length() && chars[index] != '"' {
if chars[index] == '\\' && index + 1 < chars.length() {
index = index + 1
}
value.write_char(chars[index])
index = index + 1
}
index = index + 1
delimiter = value.to_string()
} else {
while index < chars.length() && chars[index] != ' ' {
index = index + 1
}
}
// Name: a quoted string or an atom.
while index < chars.length() && chars[index] == ' ' {
index = index + 1
}
if index >= chars.length() {
return None
}
let name = if chars[index] == '"' {
index = index + 1
let value = StringBuilder()
while index < chars.length() && chars[index] != '"' {
if chars[index] == '\\' && index + 1 < chars.length() {
index = index + 1
}
value.write_char(chars[index])
index = index + 1
}
value.to_string()
} else {
let value = StringBuilder()
while index < chars.length() {
value.write_char(chars[index])
index = index + 1
}
value.to_string()
}
Some({ name, delimiter, attributes, })
}
///|
/// Parse a `UID FETCH` reply that carries only a header literal.
///
/// The IMAP server returns one `* n FETCH (...)` line per message, with the
/// flags and the header as a literal.
pub fn parse_header_fetch(line : LogicalLine) -> Envelope? {
if !line.text.to_upper().contains("FETCH") {
return None
}
let uid = match token_after(line.text, "UID") {
Some(value) => value
None => return None
}
let size = match token_int(line.text, "RFC822.SIZE") {
Some(value) => value
None => 0
}
let lower = line.text.to_upper()
let seen = lower.contains("\\SEEN")
let flagged = lower.contains("\\FLAGGED")
let header = match line.literals {
[] => Bytes::new(0)
[first, ..] => first
}
let headers = parse_headers(bytes_to_string_lossy(header))
Some({
uid,
date: header_value(headers, "date"),
from: decode_encoded_words(header_value(headers, "from")),
subject: decode_encoded_words(header_value(headers, "subject")),
size,
seen,
flagged,
})
}
///|
/// `2026-10-27 10:02` from an RFC 5322 `Date:` value, or the raw value when it
/// cannot be understood.
///
/// The terminal is narrow, so a compact local-looking stamp beats the full
/// header. Only the `dd Mon yyyy hh:mm` shape Gmail, Outlook and friends emit is
/// understood; anything else is passed through so nothing is lost.
pub fn short_date(value : String) -> String {
let text = trim(value)
if text == "" {
return ""
}
// Drop the leading weekday: "Mon, 27 Oct 2026 10:02:19 +0000"
let mut rest = text
match rest.find(",") {
Some(index) => if index <= 4 { rest = trim(rest[index + 1:].to_owned()) }
None => ()
}
let parts : Array[String] = []
for piece in rest.split(" ") {
let trimmed = trim(piece)
if trimmed != "" {
parts.push(trimmed)
}
}
if parts.length() < 4 {
return text
}
let day = parts[0]
let month = parts[1]
let year = parts[2]
let time = parts[3]
let hhmm = if time.length() >= 5 { time[0:5].to_owned() } else { time }
let month_number = match month.to_lower() {
"jan" => "01"
"feb" => "02"
"mar" => "03"
"apr" => "04"
"may" => "05"
"jun" => "06"
"jul" => "07"
"aug" => "08"
"sep" => "09"
"oct" => "10"
"nov" => "11"
"dec" => "12"
_ => ""
}
if month_number == "" {
return text
}
let padded_day = if day.length() == 1 { "0" + day } else { day }
year + "-" + month_number + "-" + padded_day + " " + hhmm
}
///|
/// Shorten `text` to `width` characters for the table, with an ellipsis.
pub fn fit_width(text : String, width : Int32) -> String {
let collapsed = collapse_whitespace(text)
if collapsed.char_length() <= width {
return collapsed
}
if width <= 1 {
return "…"
}
let out = StringBuilder()
let mut count = 0
for c in collapsed {
if count >= width - 1 {
break
}
out.write_char(c)
count = count + 1
}
out.write_string("…")
out.to_string()
}
///|
/// Pad `text` to `width` columns. Counts characters, not bytes, so a CJK
/// subject lines up with an ASCII one.
pub fn pad_to(text : String, width : Int32) -> String {
let out = StringBuilder()
out.write_string(text)
let mut count = text.char_length()
while count < width {
out.write_char(' ')
count = count + 1
}
out.to_string()
}
///|
/// The header row of `mails ls`.
pub fn envelope_table_header(
uid_width : Int32,
from_width : Int32,
subject_width : Int32,
) -> String {
pad_to("UID", uid_width) +
" " +
pad_to("DATE", 16) +
" " +
pad_to("FROM", from_width) +
" " +
// The marker column of `envelope_table_row`.
" " +
pad_to("SUBJECT", subject_width)
}
///|
/// One row of `mails ls`.
pub fn envelope_table_row(
envelope : Envelope,
uid_width : Int32,
from_width : Int32,
subject_width : Int32,
) -> String {
let mark = if envelope.seen { " " } else { "*" }
pad_to(envelope.uid, uid_width) +
" " +
pad_to(short_date(envelope.date), 16) +
" " +
pad_to(fit_width(envelope.from, from_width), from_width) +
" " +
mark +
" " +
fit_width(envelope.subject, subject_width)
}
///|
/// Render the whole table, newest first, which is the order readers expect.
fn trim_trailing_spaces(text : String) -> String {
let chars = text.to_array()
let mut end = chars.length()
while end > 0 && chars[end - 1] == ' ' {
end = end - 1
}
let out = StringBuilder()
for index in 0.. String {
if envelopes.length() == 0 {
return ""
}
let mut uid_width = 3
for envelope in envelopes {
if envelope.uid.length() > uid_width {
uid_width = envelope.uid.length()
}
}
let from_width = 24
let subject_width = 60
let lines : Array[String] = [
envelope_table_header(uid_width, from_width, subject_width),
]
for envelope in envelopes {
lines.push(
envelope_table_row(envelope, uid_width, from_width, subject_width),
)
}
// The last column is not padded, so drop the padding the header carries:
// trailing spaces only make `mails ls` noisy in a pipe.
let cleaned : Array[String] = []
for line in lines {
cleaned.push(trim_trailing_spaces(line))
}
cleaned.join("\n")
}
///|
/// `LIST "" "*"`: every folder the server knows about, with modified UTF-7
/// names decoded so `[Gmail]/&XfJT0ZAB-` prints as `[Gmail]/Sent Mail`.
pub async fn ImapClient::list_folders(client : ImapClient) -> Array[Folder] {
let reply = execute(client, "LIST \"\" \"*\"")
let out : Array[Folder] = []
for line in reply.lines {
match parse_list_line(line.text) {
Some(folder) =>
out.push({ ..folder, name: decode_modified_utf7(folder.name), })
None => ()
}
}
out
}
///|
/// Sort UID strings numerically and keep the newest `limit`.
fn newest_uids(uids : Array[String], limit : Int32) -> Array[String] {
let values : Array[Int32] = []
for raw in uids {
let value = @string.parse_int(raw) catch { _ => -1 }
if value > 0 {
values.push(value)
}
}
let mut index = 0
while index < values.length() {
let mut best = index
let mut scan = index + 1
while scan < values.length() {
if values[scan] < values[best] {
best = scan
}
scan = scan + 1
}
if best != index {
let tmp = values[index]
values[index] = values[best]
values[best] = tmp
}
index = index + 1
}
let out : Array[String] = []
let mut position = values.length() - 1
while position >= 0 && out.length() < limit {
out.push(values[position].to_string())
position = position - 1
}
out
}
///|
/// The messages of the selected mailbox, newest first.
///
/// One `UID SEARCH` to learn the UIDs, then one header-only `UID FETCH` per
/// batch of 50. Only `Date`, `From` and `Subject` are requested, so listing a
/// large mailbox stays cheap.
pub async fn ImapClient::list_envelopes(
client : ImapClient,
limit : Int32,
unread_only : Bool,
) -> Array[Envelope] {
let all = if unread_only {
let reply = execute(client, "UID SEARCH UNSEEN")
let out : Array[String] = []
for line in reply_texts(reply) {
if line.to_upper().has_prefix("* SEARCH") {
let parts = tokens(line)
let mut index = 2
while index < parts.length() {
out.push(parts[index])
index = index + 1
}
}
}
out
} else {
client.search_uids(1)
}
let wanted = newest_uids(all, limit)
let out : Array[Envelope] = []
let mut start = 0
while start < wanted.length() {
let end = if start + 50 < wanted.length() {
start + 50
} else {
wanted.length()
}
let batch = wanted[start:end]
let reply = execute(
client,
"UID FETCH " +
batch.join(",") +
" (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (DATE FROM SUBJECT)])",
)
for line in reply.lines {
match parse_header_fetch(line) {
Some(envelope) => out.push(envelope)
None => ()
}
}
start = end
}
out
}
///|
/// The raw message, headers included, for `mails read --raw` and for piping to
/// another tool.
pub async fn ImapClient::fetch_raw(client : ImapClient, uid : String) -> Bytes? {
let reply = execute(client, "UID FETCH " + uid + " (BODY.PEEK[])")
for line in reply.lines {
if line.text.to_upper().contains("FETCH") && line.literals.length() > 0 {
return Some(line.literals[0])
}
}
None
}