send.bend source
send.bend on the hub · documented module
# Send mail over SMTP.## bend send.bend -o send# SMTP_PASSWORD=... ./send --host smtp.example.com --user me@example.com \# --from "Me <me@example.com>" --to "Ana <ana@x.com>, bia@y.com" \# --cc c@z.com --bcc d@w.com --subject "Oi" --body "texto" \# --html-file corpo.html --attach relatorio.pdf --attach foto.jpg \# --header "X-Campaign: outubro"## Address lists take "Name <addr>" or "addr", comma-separated (quote a# name with a comma: "\"Silva, Ana\" <ana@x.com>"). --attach and# --header repeat. --individually sends one message per --to address,# each seeing only itself, all over one connection. --debug prints the# dialog on stderr, with secrets and the message's text left out.# --tls starttls (default; port 587), tls (implicit; port 465) or plain# (port 25); --port overrides. --auth plain, login, xoauth2 or# oauthbearer (default: picked from what the server offers). Secrets# come only from the environment: SMTP_PASSWORD, or SMTP_OAUTH_TOKEN (an# OAuth 2 access token). --cafile trusts that CA instead of the# system's; --helo names this host.## Exit: 0 sent to everyone; 3 sent in part (a refused recipient, or some# of the messages); 1 nothing sent; 2 bad usage.import Baseimport ./core.bend as Cimport ./smtp.bend as Simport ./addr.bend as Aimport ./mime.bend as M# Arguments# ---------# An option and its value, as given.type Opt is Data: Opt{key: String, val: String}# The options that take a value, and the ones that stand alone.def Args.valued() -> List<&2, String>: ["--host", "--port", "--tls", "--cafile", "--helo", "--user", "--auth", "--from", "--to", "--cc", "--bcc", "--reply-to", "--subject", "--body", "--body-file", "--html", "--html-file", "--attach", "--header", "--notify", "--ret", "--envid", "--proxy", "--cert", "--key", "--dkim-domain", "--dkim-selector", "--dkim-key", "--dkim-canon"]def Args.alone() -> List<&2, String>: ["--debug", "--individually", "--lmtp", "--no-pipelining", "--chunking", "--dkim-no-oversign"]def Args.is(ks: List<&2, String>, k: String) -> Bool: List.contains(~String, ~String.eq, ks, k)def Args.yes(add: Bool, rest: List<&2, String>) -> List<&2, String>: match add: case True{}: "yes" <> rest case False{}: rest# The arguments with a "yes" after each option that stands alone, so# they all come in pairs; val: this one is a value, not an option.def Args.pair(xs: List<&2, String>, +val: Bool) -> List<&2, String>: match xs: case Nil{}: Nil{} case Con{+k, t}: +alone = Bool.not(val) && Args.is(Args.alone(), k) k <> Args.yes(alone, Args.pair(t, Bool.not(val) && Bool.not(alone)))def Args.opts(xs: List<&2, String>) -> List<&2, Opt>: match xs: case Nil{}: Nil{} case Con{k, Nil{}}: [Opt{"?", k}] case Con{k, Con{v, rest}}: Opt{k, v} <> Args.opts(rest)# The command line as a reusable list (its head is the program itself).def Args.copy(xs: List<String>) -> List<&2, String>: match xs: case Nil{}: Nil{} case Con{x, rest}: x <> Args.copy(rest)def Args.parse(xs: List<&2, String>) -> List<&2, Opt>: Args.opts(Args.pair(xs, False{}))def Args.get.go(os: List<&2, Opt>, +k: String, found: String) -> String: match os: case Nil{}: found case Con{o, t}: Opt{key, val} = o Args.get.go(t, k, Bool.pick(String, String.eq(key, k), val, found))# An option's value (the last one given), or "".def Args.get(os: List<&2, Opt>, +k: String) -> String: Args.get.go(os, k, "")def Args.keep(yes: Bool, v: String, rest: List<&2, String>) -> List<&2, String>: match yes: case True{}: v <> rest case False{}: rest# Every value given for an option, in order.def Args.all(os: List<&2, Opt>, +k: String) -> List<&2, String>: match os: case Nil{}: Nil{} case Con{o, t}: Opt{key, val} = o Args.keep(String.eq(key, k), val, Args.all(t, k))def Args.on(os: List<&2, Opt>, k: String) -> Bool: Bool.not(String.is_empty(Args.get(os, k)))# The first option that does not exist, or lacks its value; "" if none.def Args.bad(os: List<&2, Opt>) -> String: match os: case Nil{}: "" case Con{o, t}: Opt{+key, val} = o Bool.pick(String, String.eq(key, "?"), "a value is missing after " ++ val, Bool.pick(String, Args.is(Args.valued(), key) || Args.is(Args.alone(), key), Args.bad(t), "unknown option " ++ key))# Options# -------def Send.mode(+tls: String, +port: String) -> C.Mode: Bool.pick(C.Mode, String.eq(tls, "tls"), C.Tls{}, Bool.pick(C.Mode, String.eq(tls, "plain"), C.Plain{}, Bool.pick(C.Mode, String.eq(tls, "starttls"), C.StartTls{}, Bool.pick(C.Mode, String.eq(port, "465"), C.Tls{}, C.StartTls{}))))def Send.auth(+a: String) -> C.Auth: Bool.pick(C.Auth, String.eq(a, "plain"), C.APlain{}, Bool.pick(C.Auth, String.eq(a, "login"), C.ALogin{}, Bool.pick(C.Auth, String.eq(a, "cram-md5"), C.ACram{}, Bool.pick(C.Auth, String.eq(a, "xoauth2"), C.AXoauth2{}, Bool.pick(C.Auth, String.eq(a, "oauthbearer"), C.ABearer{}, C.AAuto{})))))def Send.port.or(m: Maybe<&2, U32>, +dflt: U32) -> U32: match m: case None{}: dflt case Some{p}: pdef Send.port.dflt(mode: C.Mode) -> U32: match mode: case C.Tls{}: 465 case C.StartTls{}: 587 case C.Plain{}: 25def Send.both(+os: List<&2, Opt>, a: String, b: String) -> Bool: Args.on(os, a) && Args.on(os, b)def Send.notify.words(ws: List<&2, String>) -> Bool: match ws: case Nil{}: True{} case Con{w, t}: Args.is(["", "never", "success", "failure", "delay"], w) && Send.notify.words(t)# NOTIFY's values (RFC 3461 4.1): never alone, or any of the others.def Send.notify.ok(+ws: List<&2, String>) -> Bool: Send.notify.words(ws) && Bool.not(Args.is(ws, "never") && Nat.is_lt(1n, List.length(&2, String, ws)))def Send.some(+os: List<&2, Opt>, ks: List<&2, String>) -> Bool: match ks: case Nil{}: False{} case Con{k, t}: Args.on(os, k) || Send.some(os, t)def Send.every(+os: List<&2, Opt>, ks: List<&2, String>) -> Bool: match ks: case Nil{}: True{} case Con{k, t}: Args.on(os, k) && Send.every(os, t)# What is wrong with the options, or "".def Send.problem(+os: List<&2, Opt>) -> String: +bad = Args.bad(os) +each = Args.on(os, "--individually") Bool.pick(String, Bool.not(String.is_empty(bad)), bad, Bool.pick(String, Bool.not(Args.on(os, "--host")), "--host is required", Bool.pick(String, Bool.not(Args.on(os, "--from")), "--from is required", Bool.pick(String, Bool.not(Args.on(os, "--to") || Args.on(os, "--cc") || Args.on(os, "--bcc")), "--to, --cc or --bcc is required", Bool.pick(String, each && Bool.not(Args.on(os, "--to")), "--individually needs --to", Bool.pick(String, each && (Args.on(os, "--cc") || Args.on(os, "--bcc")), "--individually sends to each --to alone: it takes no --cc or --bcc", Bool.pick(String, Bool.not(Args.is(["", "tls", "starttls", "plain"], Args.get(os, "--tls"))), "--tls takes starttls, tls or plain", Bool.pick(String, Bool.not(Args.is(["", "plain", "login", "cram-md5", "xoauth2", "oauthbearer"], Args.get(os, "--auth"))), "--auth takes plain, login, cram-md5, xoauth2 or oauthbearer", Bool.pick(String, Bool.not(Send.notify.ok(String.split(String.to_lower( Args.get(os, "--notify")), ','))), "--notify takes never, or success, failure and delay, comma-separated", Bool.pick(String, Bool.not(Args.is(["", "full", "hdrs"], String.to_lower(Args.get(os, "--ret")))), "--ret takes full or hdrs", Bool.pick(String, Bool.not(String.is_empty(Args.get(os, "--cert"))) && String.is_empty(Args.get(os, "--key")) || String.is_empty(Args.get(os, "--cert")) && Bool.not(String.is_empty(Args.get(os, "--key"))), "--cert and --key go together", Bool.pick(String, Send.some(os, ["--dkim-domain", "--dkim-selector", "--dkim-key"]) && Bool.not(Send.every(os, ["--dkim-domain", "--dkim-selector", "--dkim-key"])), "--dkim-domain, --dkim-selector and --dkim-key go together", Bool.pick(String, Bool.not(Args.is(["", "relaxed/relaxed", "relaxed/simple", "simple/relaxed", "simple/simple", "relaxed", "simple"], String.to_lower(Args.get(os, "--dkim-canon")))), "--dkim-canon takes relaxed or simple, for the header and the body: " ++ "relaxed/relaxed (default), relaxed/simple, simple/relaxed, simple/simple", Bool.pick(String, Bool.not(String.is_empty(C.Proxy.problem(Args.get(os, "--proxy")))), C.Proxy.problem(Args.get(os, "--proxy")), Bool.pick(String, Send.both(os, "--body", "--body-file"), "--body and --body-file exclude each other", Bool.pick(String, Send.both(os, "--html", "--html-file"), "--html and --html-file exclude each other", ""))))))))))))))))# The named options of S.Opts, from the flags of the same names.def Send.more(+os: List<&2, Opt>, ks: List<&2, String>) -> List<&2, C.Kv>: match ks: case Nil{}: Nil{} case Con{+k, t}: C.Kv{k, Args.get(os, "--" ++ k)} <> Send.more(os, t)def Send.opts(+os: List<&2, Opt>, pass: String, token: String) -> C.Opts: +port = Args.get(os, "--port") +mode = Send.mode(Args.get(os, "--tls"), port) C.Opts{Args.get(os, "--host"), Send.port.or(U32.read(port), Send.port.dflt(mode)), mode, Args.get(os, "--cafile"), Args.get(os, "--helo"), Args.get(os, "--user"), pass, Send.auth(Args.get(os, "--auth")), token, Args.on(os, "--debug"), Send.more(os, ["lmtp", "notify", "ret", "envid", "no-pipelining", "chunking", "proxy", "cert", "key", "dkim-domain", "dkim-selector", "dkim-key", "dkim-canon", "dkim-no-oversign"])}# "Name: value" as a header; a line with no ":" has no name, and is# refused later.def Send.header.of(parts: List<&2, String>) -> C.Hdr: match parts: case Con{n, Con{v, rest}}: C.Hdr{String.trim(n), String.trim(String.join(v <> rest, ":"))} case _: C.Hdr{"", ""}def Send.headers(hs: List<&2, String>) -> List<&2, C.Hdr>: match hs: case Nil{}: Nil{} case Con{h, t}: Send.header.of(String.split(h, ':')) <> Send.headers(t)# The message as the options describe it.def Send.mail(+os: List<&2, Opt>, text: String, html: String, files: List<&2, M.Part>) -> C.Mail: C.Mail{A.Mbox.parse(Args.get(os, "--from")), A.Mbox.list(Args.get(os, "--to")), A.Mbox.list(Args.get(os, "--cc")), A.Mbox.list(Args.get(os, "--bcc")), A.Mbox.list(Args.get(os, "--reply-to")), Args.get(os, "--subject"), text, html, files, Send.headers(Args.all(os, "--header"))}# m with t as its only To.def Send.only(m: C.Mail, t: A.Mbox) -> C.Mail: C.Mail{from, _, cc, bcc, reply, subject, text, html, files, headers} = m C.Mail{from, [t], cc, bcc, reply, subject, text, html, files, headers}def Send.alone(+m: C.Mail, to: List<&2, A.Mbox>) -> List<&2, C.Mail>: match to: case Nil{}: Nil{} case Con{t, rest}: Send.only(m, t) <> Send.alone(m, rest)def Send.to(m: C.Mail) -> List<&2, A.Mbox>: C.Mail{_, to, _, _, _, _, _, _, _, _} = m todef Send.mails.at(each: Bool, +m: C.Mail) -> List<&2, C.Mail>: match each: case True{}: Send.alone(m, Send.to(m)) case False{}: [m]# The messages to send: the one described, or, with --individually, a# copy of it for each --to address.def Send.mails(+os: List<&2, Opt>, m: C.Mail) -> List<&2, C.Mail>: Send.mails.at(Args.on(os, "--individually"), m)# What is missing for AUTH, or "": a password for PLAIN and LOGIN, a# token for the OAuth mechanisms, one of the two otherwise.def Send.creds(+user: String, +auth: String, +pass: String, +token: String) -> String: +oauth = String.eq(auth, "xoauth2") || String.eq(auth, "oauthbearer") +basic = String.eq(auth, "plain") || String.eq(auth, "login") || String.eq(auth, "cram-md5") Bool.pick(String, String.is_empty(user), "", Bool.pick(String, oauth && String.is_empty(token), "--auth " ++ auth ++ " needs the access token in SMTP_OAUTH_TOKEN", Bool.pick(String, basic && String.is_empty(pass), "--auth " ++ auth ++ " needs the password in SMTP_PASSWORD", Bool.pick(String, String.is_empty(pass) && String.is_empty(token), "--user needs SMTP_PASSWORD or SMTP_OAUTH_TOKEN", ""))))# The report# ----------# What to print and the exit code: how many messages went and how many# did not, and a line (or more) for each.type Rep is Data: Rep{ok: U32, bad: U32, text: String}def Rep.lines(xs: List<&2, String>) -> String: match xs: case Nil{}: "" case Con{x, t}: "\n " ++ x ++ Rep.lines(t)# " to <label>" when the messages are told apart, else nothing.def Rep.to(+label: String) -> String: Bool.pick(String, String.is_empty(label), "", " to " ++ label)def Rep.refused(+bad: List<&2, String>) -> String: Bool.pick(String, List.is_empty(&2, String, bad), "", ", but these recipients were refused:" ++ Rep.lines(bad))# One message's line, and whether it went.def Rep.one(+label: String, x: C.Sent) -> Bool & String: C.Sent{+code, +error, +bad} = x (U32.is_zero(code), Bool.pick(String, U32.is_zero(code), "sent" ++ Rep.to(label) ++ Rep.refused(bad), "failed" ++ Rep.to(label) ++ " (" ++ U32.show(code) ++ "): " ++ error ++ Rep.lines(bad)))def Rep.add(r: Rep, l: Bool & String) -> Rep: Rep{ok, bad, +text} = r (+went, line) = l Rep{(ok + Bool.pick(U32, went, 1, 0) : U32), (bad + Bool.pick(U32, went, 0, 1) : U32), text ++ Bool.pick(String, String.is_empty(text), "", "\n") ++ line}# The messages tried, each with its label.def Rep.each(xs: List<&2, C.Sent>, ls: List<&2, String>, r: Rep) -> Rep: match xs ls: case Nil{} _: r case Con{x, t} Nil{}: Rep.each(t, Nil{}, Rep.add(r, Rep.one("", x))) case Con{x, t} Con{l, lt}: Rep.each(t, lt, Rep.add(r, Rep.one(l, x)))# The error that ended the connection, and the messages it left untried.def Rep.fatal(r: Rep, +code: U32, +error: String, +left: U32) -> Rep: Rep{+ok, +bad, +text} = r Bool.pick(Rep, String.is_empty(error), Rep{ok, bad, text}, Rep{ok, (bad + 1 : U32), text ++ Bool.pick(String, String.is_empty(text), "", "\n") ++ "failed (" ++ U32.show(code) ++ "): " ++ error ++ Bool.pick(String, (left > 1 : U32), "\n" ++ U32.show(left) ++ " messages were not tried", "")})# The report of a connection; labels name the messages ([""] for one).def Rep.of(+labels: List<&2, String>, b: C.Batch) -> Rep: C.Batch{+sent, code, error} = b +n = U32.from_nat(List.length(&2, String, labels)) +k = U32.from_nat(List.length(&2, C.Sent, sent)) Rep.fatal(Rep.each(sent, labels, Rep{0, 0, ""}), code, error, (n - k : U32))# 0 when every message went to everyone, 1 when none went, else 3.def Rep.exit(r: Rep) -> U32: Rep{+ok, +bad, +text} = r Bool.pick(U32, U32.is_zero(ok), 1, Bool.pick(U32, U32.is_zero(bad) && Bool.not(String.contains(text, "refused")), 0, 3))def Rep.text(r: Rep) -> String: Rep{_, _, t} = r t# IO# --def Send.say(ok: Bool, code: U32, text: String) -> IO(Unit): match ok: case True{}: IO.print(text) case False{}: IO.die(Unit, code, text)def Send.report(+r: Rep) -> IO(Unit): +code = Rep.exit(r) Send.say(U32.is_zero(code), code, Rep.text(r))def Send.env.or(r: Result<&1, &1, U32 & String, String>) -> String: match r: case Done{v}: v case Fail{_}: ""def Send.env(name: String) -> IO(String): do IO<String>: r : Result<&1, &1, U32 & String, String> <- IO.get_env(name) IO.pure(String, Send.env.or(r))def Send.read(m: File & Result<&1, &1, U32 & String, String>) -> IO(String): (f, r) = m do IO<String>: File.close(f) IO.pass(String, r)def Send.text.at(none: Bool, file: String, text: String) -> IO(String): match none: case True{}: IO.pure(String, text) case False{}: do IO<String>: f : File <- IO.try(File, File.open(file, "r")) m : File & Result<&1, &1, U32 & String, String> <- File.read(f, 16777216) Send.read(m)# A text from its file (up to 16 MiB), else as given.def Send.text(+file: String, text: String) -> IO(String): Send.text.at(String.is_empty(file), file, text)def Send.base.of(m: Maybe<&2, String>) -> String: match m: case None{}: "file" case Some{n}: n# A path's last part, the file's name.def Send.base(path: String) -> String: Send.base.of(List.last(&2, String, String.split(path, '/')))def Send.got(m: File & Result<&1, &1, U32 & String, List<&2, U32>>, +path: String) -> IO(M.Part): (f, r) = m +name = Send.base(path) do IO<M.Part>: File.close(f) bs : List<&2, U32> <- IO.pass(List<&2, U32>, r) IO.pure(M.Part, M.Part{name, M.Mime.ctype(name), bs})# One attachment: its bytes (up to 25 MiB), name and type.def Send.file(+path: String) -> IO(M.Part): do IO<M.Part>: f : File <- IO.try(File, File.open(path, "r")) m : File & Result<&1, &1, U32 & String, List<&2, U32>> <- File.read_bytes(f, 26214400) Send.got(m, path)def Send.files(ps: List<&2, String>) -> IO(List<&2, M.Part>): match ps: case Nil{}: IO.pure(List<&2, M.Part>, Nil{}) case Con{p, t}: do IO<List<&2, M.Part>>: part : M.Part <- Send.file(p) rest : List<&2, M.Part> <- Send.files(t) IO.pure(List<&2, M.Part>, part <> rest)def Send.labels.more(ms: List<&2, C.Mail>) -> List<&2, String>: match ms: case Nil{}: Nil{} case Con{m, rest}: String.join(A.Mbox.addrs(Send.to(m)), ", ") <> Send.labels.more(rest)# The labels that tell the messages apart: each one's --to address, or# one empty label for a single message.def Send.labels(ms: List<&2, C.Mail>) -> List<&2, String>: match ms: case Nil{}: Nil{} case Con{m, Nil{}}: [""] case Con{m, rest}: String.join(A.Mbox.addrs(Send.to(m)), ", ") <> Send.labels.more(rest)def Send.all(+o: C.Opts, +ms: List<&2, C.Mail>) -> IO(Unit): do IO<Unit>: b : C.Batch <- S.Smtp.send_many(o, ms) Send.report(Rep.of(Send.labels(ms), b))def Send.go(+os: List<&2, Opt>, pass: String, token: String) -> IO(Unit): do IO<Unit>: text : String <- Send.text(Args.get(os, "--body-file"), Args.get(os, "--body")) page : String <- Send.text(Args.get(os, "--html-file"), Args.get(os, "--html")) files : List<&2, M.Part> <- Send.files(Args.all(os, "--attach")) Send.all(Send.opts(os, pass, token), Send.mails(os, Send.mail(os, text, page, files)))def Send.usage() -> String: "usage: send --host H --from F (--to T | --cc C | --bcc B) [--reply-to R]" ++ " [--subject S] [--body B | --body-file P] [--html H | --html-file P]" ++ " [--attach P]... [--header \"Name: value\"]... [--individually]" ++ " [--tls starttls|tls|plain] [--port N] [--user U]" ++ " [--auth plain|login|cram-md5|xoauth2|oauthbearer] [--cafile P]" ++ " [--cert P --key P] [--helo NAME]" ++ " [--proxy socks5://[U:P@]H[:PORT] | http://[U:P@]H[:PORT]]" ++ " [--notify never|success,failure,delay] [--ret full|hdrs] [--envid ID]" ++ " [--dkim-domain D --dkim-selector S --dkim-key P" ++ " [--dkim-canon relaxed/relaxed] [--dkim-no-oversign]] [--lmtp]" ++ " [--no-pipelining] [--chunking] [--debug]" ++ "\nsecrets: SMTP_PASSWORD or SMTP_OAUTH_TOKEN"def Send.creds.ok(problem: String, +os: List<&2, Opt>, pass: String, token: String) -> IO(Unit): match problem: case SNil{}: Send.go(os, pass, token) case SCon{h, t}: IO.die(Unit, 2, SCon{h, t})def Send.secrets(+os: List<&2, Opt>, +pass: String, +token: String) -> IO(Unit): Send.creds.ok(Send.creds(Args.get(os, "--user"), Args.get(os, "--auth"), pass, token), os, pass, token)def Send.start(+os: List<&2, Opt>) -> IO(Unit): do IO<Unit>: pass : String <- Send.env("SMTP_PASSWORD") token : String <- Send.env("SMTP_OAUTH_TOKEN") Send.secrets(os, pass, token)def Send.run(problem: String, +os: List<&2, Opt>) -> IO(Unit): match problem: case SNil{}: Send.start(os) case SCon{h, t}: IO.die(Unit, 2, SCon{h, t} ++ "\n" ++ Send.usage())def Send.args(+os: List<&2, Opt>) -> IO(Unit): Send.run(Send.problem(os), os)def main() -> IO(Unit): do IO<Unit>: xs : List<String> <- IO.args() Send.args(Args.parse(List.drop(&2, String, Args.copy(xs), 1n)))