///|
/// 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
}
// An atom cannot contain spaces, so any trailing blanks are framing.
trim(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
}
///|
/// Terminal columns taken by one character: 2 for East Asian wide and
/// fullwidth characters, 0 for combining marks, 1 otherwise.
fn char_columns(c : Char) -> Int32 {
let code = c.to_int()
if (code >= 0x0300 && code <= 0x036F) || (code >= 0x200B && code <= 0x200F) {
0
} else if (code >= 0x1100 && code <= 0x115F) ||
(code >= 0x2E80 && code <= 0x303E) ||
(code >= 0x3041 && code <= 0x33FF) ||
(code >= 0x3400 && code <= 0x4DBF) ||
(code >= 0x4E00 && code <= 0x9FFF) ||
(code >= 0xA000 && code <= 0xA4CF) ||
(code >= 0xAC00 && code <= 0xD7A3) ||
(code >= 0xF900 && code <= 0xFAFF) ||
(code >= 0xFE30 && code <= 0xFE4F) ||
(code >= 0xFF00 && code <= 0xFF60) ||
(code >= 0xFFE0 && code <= 0xFFE6) {
2
} else {
1
}
}
///|
/// The number of terminal columns `text` occupies.
pub fn display_width(text : String) -> Int32 {
let mut width = 0
for c in text {
width = width + char_columns(c)
}
width
}
///|
/// Shorten `text` to at most `width` terminal columns, with an ellipsis.
pub fn fit_width(text : String, width : Int32) -> String {
let collapsed = collapse_whitespace(text)
if width <= 0 {
return ""
}
if display_width(collapsed) <= width {
return collapsed
}
if width == 1 {
return "…"
}
let out = StringBuilder()
let mut used = 0
for c in collapsed {
let columns = char_columns(c)
// Leave one column for the ellipsis; a wide character that would fill that
// column by itself is dropped rather than overflowing the column.
if used + columns > width - 1 {
break
}
out.write_char(c)
used = used + columns
}
out.write_string("…")
out.to_string()
}
///|
/// Pad `text` to `width` terminal columns. Counts columns rather than
/// characters, 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 used = display_width(text)
while used < width {
out.write_char(' ')
used = used + 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)
}
}
// A big mailbox has one UID per message, and a hand-written selection sort
// is quadratic: on a 100k-message mailbox `mails ls` looked hung.
values.sort()
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 by_uid : Map[String, Envelope] = Map([])
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) => by_uid.set(envelope.uid, envelope)
None => ()
}
}
start = end
}
// Servers answer a FETCH in mailbox (ascending UID) order, not in the order
// the UIDs were asked for, so emit them newest first as `ls` promises.
for uid in wanted {
match by_uid.get(uid) {
Some(envelope) => out.push(envelope)
None => ()
}
}
out
}
///|
/// Mailbox names servers use for sent mail when they do not advertise the
/// RFC 6154 `\Sent` flag. Chinese providers are common here: QQ Mail, 163 and
/// Tencent Exmail file sent copies under `已发送`.
let sent_mailbox_names : Array[String] = [
"sent", "sent items", "sent mail", "sent messages", "已发送", "已发送邮件",
"寄件备份", "寄件備份",
]
///|
/// Pick the Sent mailbox out of a `LIST` result.
///
/// The server's own answer wins: RFC 6154 gives the mailbox carrying the
/// `\Sent` special-use attribute, so a localized name such as `[Gmail]/已发送邮件`
/// needs no guessing. Servers without special-use (many Chinese providers,
/// older Dovecot setups) fall back to a case-insensitive name match.
fn sent_mailbox_from_folders(folders : Array[Folder]) -> String? {
for folder in folders {
if folder_is_noselect(folder) {
continue
}
for attribute in folder.attributes {
if attribute.to_upper() == "\\SENT" {
return Some(folder.name)
}
}
}
for wanted in sent_mailbox_names {
for folder in folders {
if folder_is_noselect(folder) {
continue
}
if folder.name.to_lower() == wanted {
return Some(folder.name)
}
}
}
None
}
///|
/// The mailbox a copy of a sent message belongs in, or `None` when the server
/// does not say and no conventional name matches.
pub async fn ImapClient::find_sent_mailbox(client : ImapClient) -> String? {
sent_mailbox_from_folders(client.list_folders())
}
///|
/// 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
}