# Send mail over SMTP. # # bend send.bend -o send # SMTP_PASSWORD=... ./send --host smtp.example.com --user me@example.com \ # --from "Me " --to "Ana , 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 " or "addr", comma-separated (quote a # name with a comma: "\"Silva, Ana\" "). --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 Base import ./core.bend as C import ./smtp.bend as S import ./addr.bend as A import ./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) -> 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}: p def Send.port.dflt(mode: C.Mode) -> U32: match mode: case C.Tls{}: 465 case C.StartTls{}: 587 case C.Plain{}: 25 def 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 to def 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