///| A small IMAP4rev1 client: enough of LOGIN, SELECT, UID FETCH and IDLE to
/// watch a mailbox. Every command gets its own tag and the reply is read up to
/// that tag, so unsolicited responses are kept in order.
///|
pub suberror IdleUnsupported
///|
pub suberror ImapProtocolError {
ImapProtocolError(String)
}
///|
enum ImapStream {
Plain(@socket.Tcp)
Secure(@tls.Tls)
}
///|
async fn stream_read_line(stream : ImapStream) -> String? {
match stream {
Plain(tcp) => tcp.read_until("\r\n")
Secure(tls) => tls.read_until("\r\n")
}
}
///|
async fn stream_read_exactly(stream : ImapStream, len : Int32) -> Bytes {
match stream {
Plain(tcp) => tcp.read_exactly(len)
Secure(tls) => tls.read_exactly(len)
}
}
///|
async fn stream_write(stream : ImapStream, data : Bytes) -> Unit {
match stream {
Plain(tcp) => tcp.write(data)
Secure(tls) => tls.write(data)
}
}
///|
fn stream_close(stream : ImapStream) -> Unit {
match stream {
Plain(tcp) => tcp.close()
Secure(tls) => tls.close()
}
}
///|
pub struct LogicalLine {
text : String
prefixes : Array[String]
literals : Array[Bytes]
}
///|
fn trailing_literal(line : String) -> (Int32, Int32)? {
if !line.has_suffix("}") {
return None
}
// The literal marker is the last `{N}` on the line; an earlier brace can
// appear inside a section name or a quoted string.
match line.rev_find("{") {
None => None
Some(start) => {
let digits = line[start + 1:line.length() - 1].to_owned()
if digits == "" {
return None
}
for c in digits {
if c < '0' || c > '9' {
return None
}
}
let size = @string.parse_int(digits) catch { _ => -1 }
if size < 0 {
None
} else {
Some((start, size))
}
}
}
}
///|
/// Read one protocol line, following `{N}` literals to their end.
async fn read_logical_line(stream : ImapStream) -> LogicalLine {
let text = StringBuilder()
// Text seen since the previous literal, so each literal can be attributed to
// the section name that introduces it (`BODY[HEADER]`, `BODY[TEXT]`, ...).
let segment = StringBuilder()
let prefixes : Array[String] = []
let literals : Array[Bytes] = []
let mut first = true
while true {
let line = match stream_read_line(stream) {
Some(line) => line
None => raise ImapProtocolError("connection closed while reading a reply")
}
if !first {
text.write_char(' ')
}
first = false
match trailing_literal(line) {
Some((start, size)) => {
let head = line[0:start].to_owned()
prefixes.push(segment.to_string() + head)
segment.reset()
text.write_string(head)
text.write_char(' ')
let data = stream_read_exactly(stream, size)
literals.push(data)
}
None => {
text.write_string(line)
segment.write_string(line)
break
}
}
}
{ text: text.to_string(), prefixes, literals, }
}
///|
pub struct ImapReply {
lines : Array[LogicalLine]
status : String
text : String
}
///|
fn reply_texts(reply : ImapReply) -> Array[String] {
let out : Array[String] = []
for line in reply.lines {
out.push(line.text)
}
out
}
///|
pub struct MailboxStatus {
exists : Int32
uid_validity : Int32
uid_next : Int32
}
///|
pub struct FetchedMessage {
uid : String
size : Int32
header : Bytes
body : Bytes
}
///|
pub struct ImapClient {
stream : ImapStream
host : String
mut tag_counter : Int32
mut capabilities : Array[String]
}
///|
/// Split `text` into whitespace-separated tokens, stripping parentheses.
fn tokens(text : String) -> Array[String] {
let out : Array[String] = []
for raw in text.split(" ") {
let mut token = raw.to_owned()
while token.has_prefix("(") {
token = token[1:].to_owned()
}
while token.has_suffix(")") {
token = token[0:token.length() - 1].to_owned()
}
if token != "" {
out.push(token)
}
}
out
}
///|
/// Return the token that follows `name`, if present.
fn token_after(text : String, name : String) -> String? {
let parts = tokens(text)
let mut index = 0
while index + 1 < parts.length() {
if parts[index].to_upper() == name.to_upper() {
return Some(parts[index + 1])
}
index = index + 1
}
None
}
///|
fn token_int(text : String, name : String) -> Int32? {
match token_after(text, name) {
Some(raw) => {
let value = @string.parse_int(raw) catch { _ => -1 }
if value < 0 {
None
} else {
Some(value)
}
}
None => None
}
}
///|
fn response_code(text : String, code : String) -> Int32? {
// e.g. "* OK [UIDVALIDITY 1234] UIDs valid"
let marker = "[" + code
match text.find(marker) {
None => None
Some(index) => {
let rest = text[index + marker.length():].to_owned()
let digits = StringBuilder()
for c in rest {
if c >= '0' && c <= '9' {
digits.write_char(c)
} else if digits.to_string() != "" {
break
}
}
let raw = digits.to_string()
if raw == "" {
None
} else {
let value = @string.parse_int(raw) catch { _ => -1 }
if value < 0 {
None
} else {
Some(value)
}
}
}
}
}
///|
fn encode_modified_utf7(name : String) -> String {
let out = StringBuilder()
let pending : Array[Char] = []
let flush = () => {
if pending.length() == 0 {
return
}
let bytes : Array[Byte] = []
for c in pending {
let code = c.to_int()
bytes.push(((code >> 8) & 0xFF).to_byte())
bytes.push((code & 0xFF).to_byte())
}
pending.clear()
let encoded = @base64.encode(Bytes::from_array(bytes[:]))
out.write_char('&')
for c in encoded {
if c == '/' {
out.write_char(',')
} else if c == '=' {
break
} else {
out.write_char(c)
}
}
out.write_char('-')
}
for c in name {
if c == '&' {
flush()
out.write_string("&-")
} else if c.to_int() >= 0x20 && c.to_int() < 0x7F {
flush()
out.write_char(c)
} else {
pending.push(c)
}
}
flush()
out.to_string()
}
///|
/// Decode an IMAP modified-UTF-7 mailbox name.
pub fn decode_modified_utf7(name : String) -> String {
let out = StringBuilder()
let chars = name.to_array()
let mut index = 0
while index < chars.length() {
let c = chars[index]
if c != '&' {
out.write_char(c)
index = index + 1
continue
}
// Find the closing '-' of this shift sequence.
let mut end = index + 1
while end < chars.length() && chars[end] != '-' {
end = end + 1
}
if end >= chars.length() {
out.write_char('&')
index = index + 1
continue
}
let encoded = name[index + 1:end].to_owned()
if encoded == "" {
out.write_char('&')
} else {
let normal = StringBuilder()
for c in encoded {
if c == ',' {
normal.write_char('/')
} else {
normal.write_char(c)
}
}
let mut padded = normal.to_string()
while padded.length() % 4 != 0 {
padded = padded + "="
}
let bytes = @base64.decode_lossy(padded)
let mut offset = 0
while offset + 1 < bytes.length() {
let code = bytes[offset].to_int() * 256 + bytes[offset + 1].to_int()
out.write_char(code.unsafe_to_char())
offset = offset + 2
}
}
index = end + 1
}
out.to_string()
}
///|
fn next_tag(client : ImapClient) -> String {
client.tag_counter = client.tag_counter + 1
"A" + client.tag_counter.to_string()
}
///|
/// A command as it is safe to quote in logs and error messages.
///
/// `execute` raises an error containing the command it sent, and for `LOGIN`
/// that command carries the plaintext password, so the credential has to be
/// stripped before the message can reach a log file or the journal.
fn redact_command(command : String) -> String {
let upper = command.to_upper()
if upper.has_prefix("LOGIN ") {
// LOGIN -- keep the user, drop the secret.
let rest = command[6:].to_owned()
let user = match rest.find(" ") {
Some(index) => rest[:index].to_owned()
None => rest
}
"LOGIN " + user + " "
} else if upper.has_prefix("AUTHENTICATE ") {
// AUTHENTICATE [initial-response]
let rest = command[13:].to_owned()
let mechanism = match rest.find(" ") {
Some(index) => rest[:index].to_owned()
None => rest
}
"AUTHENTICATE " + mechanism + " "
} else {
command
}
}
///|
/// Split a tagged reply line into its status word and the text after it.
fn tagged_status(line : LogicalLine, tag : String) -> (String, String) {
let head = line.text
let rest = head[tag.length() + 1:].to_owned()
let parts = tokens(rest)
if parts.length() > 0 {
(parts[0].to_upper(), rest[parts[0].length():].to_owned())
} else {
("BAD", rest)
}
}
///|
/// Send one command and collect every untagged line up to its tagged reply.
async fn execute(client : ImapClient, command : String) -> ImapReply {
let tag = next_tag(client)
let line = tag + " " + command + "\r\n"
stream_write(client.stream, string_to_bytes(line))
let lines : Array[LogicalLine] = []
let mut status = "BAD"
let mut text = ""
let mut done = false
while !done {
let reply = read_logical_line(client.stream)
let head = reply.text
if head.has_prefix(tag + " ") {
let rest = head[tag.length() + 1:].to_owned()
let parts = tokens(rest)
if parts.length() > 0 {
status = parts[0].to_upper()
text = rest[parts[0].length():].to_owned()
}
done = true
} else if head.has_prefix("+") {
// Unexpected continuation; commands that expect one handle it themselves.
lines.push(reply)
} else {
lines.push(reply)
}
}
let reply : ImapReply = { lines, status, text, }
if status == "NO" || status == "BAD" {
raise ImapProtocolError(
"server rejected '" +
redact_command(command) +
"': " +
status +
" " +
text,
)
}
reply
}
///|
/// How to prove identity to the server.
pub enum Credential {
/// A password or app password, sent with SASL `PLAIN` or IMAP `LOGIN`.
Password(String)
/// SASL `XOAUTH2` with an OAuth2 access token.
Bearer(String)
}
///|
/// The SASL `PLAIN` response: `\0user\0password`, base64 encoded.
///
/// `LOGIN` takes its arguments as quoted strings, which RFC 3501 limits to
/// 7-bit and which cannot hold a newline at all. `PLAIN` carries the same two
/// values as bytes, so a Chinese login name or a password with symbols outside
/// ASCII needs no guessing about how the server reads them.
pub fn sasl_plain_response(username : String, password : String) -> String {
base64_encode(string_to_bytes("\u{0}" + username + "\u{0}" + password))
}
///|
/// True when a value cannot appear inside an IMAP quoted string.
///
/// RFC 3501 forbids `CR` and `LF` there, and a newline would be worse than
/// rejected: it ends the command line, so a credential holding one could smuggle
/// the rest of itself in as a separate command. `NUL` ends a C string.
fn unquotable(text : String) -> Bool {
text.contains("\r") || text.contains("\n") || text.contains("\u{0}")
}
///|
/// Authenticate with SASL `PLAIN`.
///
/// A server advertising `SASL-IR` (RFC 4959) takes the response inside the
/// command; the others send an empty `+` challenge first, and the response goes
/// on a line of its own.
pub async fn ImapClient::authenticate_plain(
client : ImapClient,
username : String,
password : String,
sasl_ir : Bool,
) -> Unit {
let tag = next_tag(client)
let response = sasl_plain_response(username, password)
let command = if sasl_ir {
tag + " AUTHENTICATE PLAIN " + response + "\r\n"
} else {
tag + " AUTHENTICATE PLAIN\r\n"
}
stream_write(client.stream, string_to_bytes(command))
let mut answered = sasl_ir
let mut done = false
while !done {
let reply = read_logical_line(client.stream)
let head = reply.text
if head.has_prefix(tag + " ") {
let (status, text) = tagged_status(reply, tag)
done = true
if status != "OK" {
raise ImapProtocolError(
"server rejected 'AUTHENTICATE PLAIN ': " +
status +
" " +
text,
)
}
} else if head.has_prefix("+") && !answered {
stream_write(client.stream, string_to_bytes(response + "\r\n"))
answered = true
}
}
}
///|
/// The capability tokens of a `CAPABILITY` reply, uppercased.
fn parse_capabilities(reply : ImapReply) -> Array[String] {
let capabilities : Array[String] = []
for line in reply_texts(reply) {
if line.to_upper().has_prefix("* CAPABILITY") {
for token in tokens(line[13:].to_owned()) {
capabilities.push(token.to_upper())
}
}
}
capabilities
}
///|
/// Prove a password, with `AUTHENTICATE PLAIN` when the server offers it and
/// `LOGIN` otherwise.
///
/// `PLAIN` is preferred because it has no charset limit: a credential that
/// `LOGIN` cannot carry (non-ASCII is fine in practice but outside the RFC, and
/// a control character breaks the line) either works here or is refused with a
/// clear error instead of a mangled command.
async fn authenticate_password(
client : ImapClient,
username : String,
password : String,
) -> Unit {
let offered = parse_capabilities(execute(client, "CAPABILITY"))
if offered.contains("AUTH=PLAIN") {
client.authenticate_plain(username, password, offered.contains("SASL-IR"))
return
}
if unquotable(username) || unquotable(password) {
client.close()
raise ImapProtocolError(
"the credential contains a control character, which LOGIN cannot carry, " +
"and the server does not offer AUTH=PLAIN",
)
}
ignore(
execute(
client,
"LOGIN " + imap_quote(username) + " " + imap_quote(password),
),
)
}
///|
/// The SASL initial response for XOAUTH2 (Gmail and Microsoft both accept it):
/// `user=\x01auth=Bearer \x01\x01`, base64 encoded.
pub fn xoauth2_initial_response(
username : String,
access_token : String,
) -> String {
base64_encode(
string_to_bytes(
"user=" + username + "\u{1}auth=Bearer " + access_token + "\u{1}\u{1}",
),
)
}
///|
/// Authenticate with an OAuth2 access token.
///
/// The server either answers `OK` straight away or, on failure, sends a `+`
/// continuation carrying a base64 JSON error. In the latter case the client
/// must send an empty line to let the server finish with its tagged `NO`.
pub async fn ImapClient::authenticate_xoauth2(
client : ImapClient,
username : String,
access_token : String,
) -> Unit {
let tag = next_tag(client)
let response = xoauth2_initial_response(username, access_token)
let line = tag + " AUTHENTICATE XOAUTH2 " + response + "\r\n"
stream_write(client.stream, string_to_bytes(line))
let mut finished = false
while !finished {
let reply = read_logical_line(client.stream)
let head = reply.text
if head.has_prefix(tag + " ") {
let rest = head[tag.length() + 1:].to_owned()
let parts = tokens(rest)
let status = if parts.length() > 0 { parts[0].to_upper() } else { "BAD" }
let text = if parts.length() > 0 {
rest[parts[0].length():].to_owned()
} else {
rest
}
if status == "OK" {
finished = true
} else {
raise ImapProtocolError(
"server rejected 'AUTHENTICATE XOAUTH2 ': " +
status +
" " +
text,
)
}
} else if head.has_prefix("+") {
// An error challenge; answer with an empty line to close the exchange.
stream_write(client.stream, string_to_bytes("\r\n"))
} else {
// Untagged line; keep reading until the tagged reply arrives.
continue
}
}
}
///|
/// Open a connection and log in with a password.
pub async fn ImapClient::connect(
host : String,
port : Int32,
use_tls : Bool,
starttls : Bool,
verify_tls : Bool,
username : String,
password : String,
) -> ImapClient {
ImapClient::connect_with(
host,
port,
use_tls,
starttls,
verify_tls,
username,
Password(password),
)
}
///|
/// Upgrade a plaintext connection with `STARTTLS`.
///
/// This happens before any credential is sent, so the password never crosses
/// the wire in the clear. The capability list seen before the upgrade describes
/// the plaintext session only, and the caller asks again afterwards.
async fn upgrade_starttls(client : ImapClient, verify_tls : Bool) -> ImapClient {
let offered = parse_capabilities(execute(client, "CAPABILITY"))
if !offered.contains("STARTTLS") {
raise ImapProtocolError(
"the IMAP server does not offer STARTTLS; use port 993 with tls = true, " +
"or set starttls = false to connect in the clear",
)
}
ignore(execute(client, "STARTTLS"))
let trust = if verify_tls { @tls.SystemRoot } else { @tls.NoVerification }
let stream = match client.stream {
Plain(tcp) => Secure(@tls.Tls::client(tcp, host=client.host, trust~))
// Nothing to upgrade: the connection is already encrypted.
Secure(tls) => Secure(tls)
}
{ ..client, stream, }
}
///|
/// Open a connection and authenticate with any credential.
///
/// `use_tls` wraps the socket straight away (port 993); otherwise the greeting
/// arrives in the clear and `starttls` decides whether to upgrade before
/// authenticating.
pub async fn ImapClient::connect_with(
host : String,
port : Int32,
use_tls : Bool,
starttls : Bool,
verify_tls : Bool,
username : String,
credential : Credential,
) -> ImapClient {
let tcp = @socket.Tcp::connect_to_host(host, port~) catch {
err =>
raise ImapProtocolError(
"cannot connect to " + host + ": " + err.to_string(),
)
}
tcp.enable_keepalive() catch {
_ => ()
}
if !use_tls && !starttls {
log_warn(
"connecting to " +
host +
" without TLS: the credentials travel in the clear",
)
}
let stream : ImapStream = if use_tls {
let trust = if verify_tls { @tls.SystemRoot } else { @tls.NoVerification }
Secure(@tls.Tls::client(tcp, host~, trust~))
} else {
Plain(tcp)
}
let greeting = match stream_read_line(stream) {
Some(line) => line
None => {
stream_close(stream)
raise ImapProtocolError("no IMAP greeting from " + host)
}
}
if !(greeting.to_upper().has_prefix("* OK") &&
!greeting.to_upper().has_prefix("* BYE")) {
stream_close(stream)
raise ImapProtocolError("unexpected IMAP greeting: " + greeting)
}
let plain : ImapClient = { stream, host, tag_counter: 0, capabilities: [], }
let client = if !use_tls && starttls {
let attempt = Ok(upgrade_starttls(plain, verify_tls)) catch {
err => Err(err)
}
match attempt {
Ok(upgraded) => upgraded
Err(err) => {
// Nothing was authenticated yet; only the socket has to go.
plain.close() catch {
_ => ()
}
raise err
}
}
} else {
plain
}
// The greeting may carry a `[CAPABILITY ...]` code, but capabilities can
// change after an upgrade and after authentication (IDLE is often advertised
// only then), so the list this client trusts is always the one from
// CAPABILITY below.
match credential {
Password(password) => authenticate_password(client, username, password)
Bearer(access_token) => client.authenticate_xoauth2(username, access_token)
}
client.capabilities = parse_capabilities(execute(client, "CAPABILITY"))
client
}
///|
pub fn ImapClient::has_capability(client : ImapClient, name : String) -> Bool {
let wanted = name.to_upper()
for capability in client.capabilities {
if capability == wanted {
return true
}
}
false
}
///|
pub async fn ImapClient::close(client : ImapClient) -> Unit {
let line = next_tag(client) + " LOGOUT\r\n"
stream_write(client.stream, string_to_bytes(line)) catch {
_ => ()
}
stream_close(client.stream)
}
///|
/// SELECT a mailbox and read its UID bookkeeping.
pub async fn ImapClient::select_mailbox(
client : ImapClient,
mailbox : String,
) -> MailboxStatus {
let reply = execute(
client,
"SELECT " + imap_quote(encode_modified_utf7(mailbox)),
)
let mut status : MailboxStatus = { exists: 0, uid_validity: 0, uid_next: 0, }
for line in reply_texts(reply) {
let upper = line.to_upper()
if upper.has_prefix("* ") && upper.has_suffix(" EXISTS") {
let parts = tokens(line)
if parts.length() >= 2 {
let value = @string.parse_int(parts[1]) catch { _ => -1 }
if value >= 0 {
status = { ..status, exists: value, }
}
}
}
match response_code(upper, "UIDVALIDITY") {
Some(value) => status = { ..status, uid_validity: value, }
None => ()
}
match response_code(upper, "UIDNEXT") {
Some(value) => status = { ..status, uid_next: value, }
None => ()
}
}
status
}
///|
/// Fetch one message's header and (bounded) text body by UID.
pub async fn ImapClient::fetch_message(
client : ImapClient,
uid : String,
max_body_bytes : Int32,
) -> FetchedMessage? {
let command = "UID FETCH " +
uid +
" (UID RFC822.SIZE BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0." +
max_body_bytes.to_string() +
">)"
let reply = execute(client, command)
let mut header : Bytes? = None
let mut body : Bytes? = None
let mut size = 0
let mut fetched_uid = uid
for line in reply.lines {
let upper = line.text.to_upper()
if !upper.contains("FETCH") {
continue
}
match token_after(line.text, "UID") {
Some(value) => fetched_uid = value
None => ()
}
match token_int(line.text, "RFC822.SIZE") {
Some(value) => size = value
None => ()
}
let mut index = 0
while index < line.literals.length() {
let prefix = if index < line.prefixes.length() {
line.prefixes[index].to_upper()
} else {
""
}
if prefix.contains("BODY[HEADER]") {
header = Some(line.literals[index])
} else if prefix.contains("BODY[TEXT]") {
body = Some(line.literals[index])
}
index = index + 1
}
}
match header {
Some(header_bytes) => {
let body_bytes = match body {
Some(value) => value
None => Bytes::new(0)
}
Some({ uid: fetched_uid, size, header: header_bytes, body: body_bytes, })
}
None => None
}
}
///|
/// Try to enter IDLE and wait for a change. Falls back to an error the caller
/// can translate into polling.
pub async fn ImapClient::idle(client : ImapClient, timeout_ms : Int32) -> Bool {
let tag = next_tag(client)
stream_write(client.stream, string_to_bytes(tag + " IDLE\r\n"))
// Wait for the continuation request.
let mut started = false
while !started {
let line = read_logical_line(client.stream)
let head = line.text
if head.has_prefix(tag + " ") {
raise IdleUnsupported
}
if head.has_prefix("+") {
started = true
}
}
let mut changed = false
let mut keep_waiting = true
while keep_waiting {
let next = @async.with_timeout_opt(timeout_ms, () => {
read_logical_line(client.stream)
}) catch {
err => {
log_warn("IDLE read failed: " + err.to_string())
None
}
}
match next {
None => keep_waiting = false
Some(line) => {
let upper = line.text.to_upper()
if upper.contains(" EXISTS") || upper.contains(" RECENT") {
changed = true
keep_waiting = false
}
}
}
}
stream_write(client.stream, string_to_bytes("DONE\r\n"))
// Drain until the server confirms the end of IDLE.
let mut finished = false
while !finished {
let line = @async.with_timeout_opt(30_000, () => {
read_logical_line(client.stream)
}) catch {
_ => None
}
match line {
None => finished = true
Some(reply) => if reply.text.has_prefix(tag + " ") { finished = true }
}
}
changed
}
///|
/// APPEND a complete message to `mailbox`.
///
/// The command ends in a `{N}` literal, so the server has to answer `+` before
/// the message itself is written. `seen` files the copy as already read, which
/// is what a sent copy should look like.
///
/// APPEND needs no SELECT, so the mailbox the connection has open is untouched.
pub async fn ImapClient::append_message(
client : ImapClient,
mailbox : String,
data : Bytes,
seen : Bool,
) -> Unit {
let tag = next_tag(client)
let flags = if seen { " (\\Seen)" } else { "" }
let command = tag +
" APPEND " +
imap_quote(encode_modified_utf7(mailbox)) +
flags +
" {" +
data.length().to_string() +
"}\r\n"
stream_write(client.stream, string_to_bytes(command))
// Wait for the continuation. A tagged reply first is a rejection: writing the
// literal then would be interpreted as the next command.
let mut accepted = false
while !accepted {
let reply = read_logical_line(client.stream)
if reply.text.has_prefix(tag + " ") {
let (status, text) = tagged_status(reply, tag)
raise ImapProtocolError(
"server rejected 'APPEND " +
imap_quote(mailbox) +
"': " +
status +
" " +
text,
)
}
if reply.text.has_prefix("+") {
accepted = true
}
}
stream_write(client.stream, data)
stream_write(client.stream, string_to_bytes("\r\n"))
let mut finished = false
while !finished {
let reply = read_logical_line(client.stream)
if reply.text.has_prefix(tag + " ") {
let (status, text) = tagged_status(reply, tag)
if status == "NO" || status == "BAD" {
raise ImapProtocolError(
"server rejected 'APPEND " +
imap_quote(mailbox) +
"': " +
status +
" " +
text,
)
}
finished = true
}
}
}
///|
/// Ask the server for the current mailbox state without changing selection.
pub async fn ImapClient::check(
client : ImapClient,
mailbox : String,
) -> MailboxStatus {
client.select_mailbox(mailbox)
}
///|
/// List the UIDs at or above `from_uid`, using `UID SEARCH UID n:*`.
pub async fn ImapClient::search_uids(
client : ImapClient,
from_uid : Int32,
) -> Array[String] {
let reply = execute(client, "UID SEARCH UID " + from_uid.to_string() + ":*")
let out : Array[String] = []
for line in reply_texts(reply) {
let upper = line.to_upper()
if upper.has_prefix("* SEARCH") {
let parts = tokens(line)
let mut index = 2
while index < parts.length() {
out.push(parts[index])
index = index + 1
}
}
}
out
}
///|
/// The highest UID in the selected mailbox, or 0 when it is empty.
///
/// Used to start watching after the mail that is already there when neither a
/// saved cursor nor `UIDNEXT` is available. `UID SEARCH UID 1:*` answers with
/// every UID, and servers that answer a `n:*` range with the newest UID even
/// when it is below `n` make the last token unreliable, so take the maximum.
pub async fn ImapClient::highest_uid(client : ImapClient) -> Int32 {
let mut highest = 0
for raw in client.search_uids(1) {
let value = @string.parse_int(raw) catch { _ => -1 }
if value > highest {
highest = value
}
}
highest
}