///|
/// The browsing and sending commands: `ls`, `read` and `send`.
///
/// They share the connection and credential helpers with `watch`, so an account
/// that `mails check` accepts is an account these commands can use. Everything
/// here prints to stdout and exits; none of it touches a cursor or a rule.
///|
/// Options the three new commands need, kept out of `CliOptions` so the watcher
/// path stays untouched.
pub struct MailOptions {
mut mailbox : String
mut limit : Int32
mut unread_only : Bool
mut folders : Bool
mut json : Bool
to : Array[String]
cc : Array[String]
bcc : Array[String]
mut subject : String
mut body : String?
mut body_file : String?
mut html : Bool
attach : Array[String]
mut full : Bool
mut raw : Bool
}
///|
fn default_mail_options() -> MailOptions {
{
mailbox: "INBOX",
limit: 20,
unread_only: false,
folders: false,
json: false,
to: [],
cc: [],
bcc: [],
subject: "",
body: None,
body_file: None,
html: false,
attach: [],
full: false,
raw: false,
}
}
///|
/// Parse the options of `ls`, `read` and `send` out of the raw argv.
///
/// Returned together with the positional arguments, because `read` takes a UID
/// and `send` takes none.
fn parse_mail_options(
argv : Array[String],
) -> (MailOptions, Array[String]) raise {
let opts = default_mail_options()
let positionals : Array[String] = []
let mut index = 0
while index < argv.length() {
let arg = argv[index]
let value = () => {
index = index + 1
if index >= argv.length() {
raise cli_error(arg + " needs a value")
}
argv[index]
}
if arg == "--mailbox" || arg == "-m" {
opts.mailbox = value()
} else if arg == "--limit" || arg == "-n" {
let raw = value()
let parsed = @string.parse_int(raw) catch {
_ => raise cli_error("--limit needs a number, not '" + raw + "'")
}
if parsed <= 0 {
raise cli_error("--limit must be positive")
}
opts.limit = parsed
} else if arg == "--unread" || arg == "-u" || arg == "--unread-only" {
opts.unread_only = true
} else if arg == "--folders" || arg == "-f" {
opts.folders = true
} else if arg == "--json" {
opts.json = true
} else if arg == "--full" {
opts.full = true
} else if arg == "--raw" {
opts.raw = true
} else if arg == "--to" || arg == "-t" {
for address in split_addresses(value()) {
opts.to.push(address)
}
} else if arg == "--cc" {
for address in split_addresses(value()) {
opts.cc.push(address)
}
} else if arg == "--bcc" {
for address in split_addresses(value()) {
opts.bcc.push(address)
}
} else if arg == "--subject" || arg == "-s" {
opts.subject = value()
} else if arg == "--body" || arg == "-b" {
opts.body = Some(value())
} else if arg == "--body-file" {
opts.body_file = Some(value())
} else if arg == "--html" {
opts.html = true
} else if arg == "--attach" || arg == "-a" {
opts.attach.push(value())
} else if arg.has_prefix("-") && arg != "-" {
raise cli_error("unknown option " + arg)
} else {
positionals.push(arg)
}
index = index + 1
}
(opts, positionals)
}
///|
/// Resolve the one account a browsing or sending command works on.
fn single_account(config : Config, name : String?) -> Account raise {
let accounts = selected_accounts(config, name) catch {
err => raise cli_error(error_message(err))
}
if accounts.length() == 1 {
return accounts[0]
}
let names : Array[String] = []
for account in accounts {
names.push(account.name)
}
raise cli_error(
"several accounts match; pass --account (" + names.join(", ") + ")",
)
}
///|
/// Connect to the account's IMAP server with whatever credential it uses.
async fn open_mailbox(
account : Account,
config_dir : String,
key : String,
mailbox : String,
) -> (ImapClient, MailboxStatus) {
let credential = credential_for(account, config_dir, key) catch {
err => raise cli_error(error_message(err))
}
let client = ImapClient::connect_with(
account.host,
account.port,
account.use_tls,
account.tls_verify,
account.username,
credential,
) catch {
err => raise cli_error(error_message(err))
}
let status = client.select_mailbox(mailbox) catch {
err => {
client.close() catch {
_ => ()
}
raise cli_error(error_message(err))
}
}
(client, status)
}
///|
/// `mails ls` — list folders, or the mail in one folder.
async fn cmd_ls(opts : CliOptions, mail : MailOptions) -> Unit {
let (config, _env, key) = config_and_env(opts)
let account = single_account(config, opts.account)
let credential = credential_for(account, config.dir, key) catch {
err => raise cli_error(error_message(err))
}
let client = ImapClient::connect_with(
account.host,
account.port,
account.use_tls,
account.tls_verify,
account.username,
credential,
) catch {
err => raise cli_error(error_message(err))
}
defer (client.close() catch { _ => () })
if mail.folders {
let folders = client.list_folders() catch {
err => raise cli_error(error_message(err))
}
if mail.json {
let items : Array[String] = []
for folder in folders {
items.push(
"{\"name\":\"" +
json_escape(folder.name) +
"\",\"delimiter\":\"" +
json_escape(folder.delimiter) +
"\",\"selectable\":" +
(if folder_is_noselect(folder) { "false" } else { "true" }) +
"}",
)
}
print_line("[" + items.join(",") + "]")
return
}
for folder in folders {
if folder_is_noselect(folder) {
continue
}
print_line(folder.name)
}
return
}
ignore(
client.select_mailbox(mail.mailbox) catch {
err => raise cli_error(error_message(err))
},
)
let envelopes = client.list_envelopes(mail.limit, mail.unread_only) catch {
err => raise cli_error(error_message(err))
}
if mail.json {
let items : Array[String] = []
for envelope in envelopes {
items.push(
"{\"uid\":\"" +
json_escape(envelope.uid) +
"\",\"date\":\"" +
json_escape(envelope.date) +
"\",\"from\":\"" +
json_escape(envelope.from) +
"\",\"subject\":\"" +
json_escape(envelope.subject) +
"\",\"size\":" +
envelope.size.to_string() +
",\"seen\":" +
(if envelope.seen { "true" } else { "false" }) +
",\"flagged\":" +
(if envelope.flagged { "true" } else { "false" }) +
"}",
)
}
print_line("[" + items.join(",") + "]")
return
}
let table = render_envelope_table(envelopes)
if table == "" {
print_line("no mail in " + mail.mailbox)
return
}
print_line(table)
}
///|
fn folder_is_noselect(folder : Folder) -> Bool {
for attribute in folder.attributes {
if attribute.to_upper() == "\\NOSELECT" {
return true
}
}
false
}
///|
/// `mails read` — print one message.
async fn cmd_read(opts : CliOptions, mail : MailOptions, uid : String) -> Unit {
// Check the UID before opening a connection: a typo should not cost a login.
let parsed = @string.parse_int(uid) catch { _ => 0 }
if parsed <= 0 {
raise cli_error("'" + uid + "' is not a message UID; see `mails ls`")
}
let (config, _env, key) = config_and_env(opts)
let account = single_account(config, opts.account)
let (client, _status) = open_mailbox(account, config.dir, key, mail.mailbox)
defer (client.close() catch { _ => () })
let fetched = client.fetch_message(uid, account.max_body_bytes) catch {
err => raise cli_error(error_message(err))
}
let message = match fetched {
Some(value) => value
None =>
raise cli_error("no message with uid " + uid + " in " + mail.mailbox)
}
if mail.raw {
// Everything as the server sent it, for piping into another program.
@stdio.stdout.write(bytes_to_string_lossy(message.header))
@stdio.stdout.write(bytes_to_string_lossy(message.body))
return
}
let headers = parse_headers(bytes_to_string_lossy(message.header))
let body = message_body_text_with_headers(headers, message.body)
if mail.json {
print_line(
"{\"uid\":\"" +
json_escape(message.uid) +
"\",\"mailbox\":\"" +
json_escape(mail.mailbox) +
"\",\"from\":\"" +
json_escape(decode_encoded_words(header_value(headers, "from"))) +
"\",\"to\":\"" +
json_escape(decode_encoded_words(header_value(headers, "to"))) +
"\",\"subject\":\"" +
json_escape(decode_encoded_words(header_value(headers, "subject"))) +
"\",\"date\":\"" +
json_escape(header_value(headers, "date")) +
"\",\"body\":\"" +
json_escape(body) +
"\"}",
)
return
}
print_line("From: " + decode_encoded_words(header_value(headers, "from")))
print_line("To: " + decode_encoded_words(header_value(headers, "to")))
print_line("Date: " + header_value(headers, "date"))
print_line(
"Subject: " + decode_encoded_words(header_value(headers, "subject")),
)
print_line("UID: " + message.uid + " (" + mail.mailbox + ")")
print_line("")
let text = if mail.full { body } else { truncate_chars(body, 4_000) }
print_line(text)
}
///|
/// Read the message body from `--body`, `--body-file`, or stdin.
///
/// stdin is only read when neither option is given, so `mails send` in a script
/// does not block waiting for input it was never going to get.
async fn read_body(mail : MailOptions) -> String {
match mail.body {
Some(text) => return text
None => ()
}
match mail.body_file {
Some(path) => {
let data = @fs.read_file(path) catch {
err => raise cli_error("cannot read " + path + ": " + err.to_string())
}
return data.text()
}
None => ()
}
let data = @stdio.stdin.read_all() catch {
err =>
raise cli_error("cannot read the message from stdin: " + err.to_string())
}
data.text()
}
///|
/// `mails send` — deliver one message over SMTP.
async fn cmd_send(opts : CliOptions, mail : MailOptions) -> Unit {
let (config, _env, key) = config_and_env(opts)
let account = single_account(config, opts.account)
if is_blank(account.smtp_host) {
raise cli_error(
"account '" +
account.name +
"' has no smtp_host; add one to send mail (see mails.toml.example)",
)
}
if mail.to.length() == 0 {
raise cli_error("no recipient; use --to someone@example.com")
}
let body = read_body(mail)
if trim(body) == "" {
raise cli_error("the message body is empty")
}
let attachments : Array[Attachment] = []
for path in mail.attach {
let data = @fs.read_file(path) catch {
err => raise cli_error("cannot read " + path + ": " + err.to_string())
}
attachments.push({
name: path,
mime: guess_mime_type(path),
data: data.binary(),
})
}
let from = if is_blank(account.username) {
raise cli_error(
"account '" + account.name + "' has no username to send from",
)
} else {
account.username
}
let draft : Draft = {
from,
to: mail.to,
cc: mail.cc,
bcc: mail.bcc,
subject: mail.subject,
body,
html: mail.html,
attachments,
}
let unique = random_token()
let message = render_outgoing_message(draft, now_seconds(), unique)
let recipients = draft_recipients(draft)
let credential = credential_for(account, config.dir, key) catch {
err => raise cli_error(error_message(err))
}
let password = match credential {
Password(value) => value
Bearer(_) => ""
}
let access_token = match credential {
Bearer(value) => Some(value)
Password(_) => None
}
let client = SmtpClient::connect(
account.smtp_host,
account.smtp_port,
account.smtp_tls,
account.smtp_starttls,
account.smtp_tls_verify,
) catch {
err => raise cli_error(error_message(err))
}
let auth = choose_smtp_auth(
client.capabilities,
account.smtp_username,
password,
access_token,
)
client.authenticate(auth) catch {
err => {
client.close() catch {
_ => ()
}
raise cli_error(error_message(err))
}
}
client.send({ from, to: recipients, }, string_to_bytes(message)) catch {
err => {
client.close() catch {
_ => ()
}
raise cli_error(error_message(err))
}
}
client.close() catch {
_ => ()
}
print_line(
"sent to " +
recipients.join(", ") +
" via " +
account.smtp_host +
":" +
account.smtp_port.to_string(),
)
}
///|
/// A short token for `Message-ID` and multipart boundaries, from `/dev/urandom`.
async fn random_token() -> String {
let bytes = @fs.open("/dev/urandom", mode=ReadOnly) catch {
_ => return random_fallback()
}
defer bytes.close()
let data = bytes.read_exactly(8) catch { _ => return random_fallback() }
let digits : Array[Char] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
]
let out = StringBuilder()
for byte in data {
let value = byte.to_int()
out.write_char(digits[value >> 4])
out.write_char(digits[value & 0xF])
}
out.to_string()
}
///|
fn random_fallback() -> String {
"0000000000000000"
}
///|
/// Seconds since the Unix epoch, which is all a `Date:` header needs.
///
/// The C stub is `mails_unix_time` in `time_stub.c`; it is the only piece of
/// the sender that is not portable MoonBit.
extern "C" fn mails_unix_time() -> Int64 = "mails_unix_time"
///|
fn now_seconds() -> Int64 {
mails_unix_time()
}