addr.bend source
addr.bend on the hub · documented module
# Addresses and mailboxes. An address is local@domain, a dot-atom (RFC# 5321 4.1.2; RFC 6531 3.3 for UTF-8); a mailbox is an address with an# optional display name, "Maria Silva <maria@example.com>" (RFC 5322# 3.4). Lists are read the way people write them, commas inside quotes# or angle brackets included, and written back as header values.import Baseimport ./text.bend as Timport ./mime.bend as Mimport ./idna.bend as Itype Mbox is Data: Mbox{name: String, addr: String}# Addresses# ---------# atext (RFC 5322 3.2.3): letters, digits and !#$%&'*+-/=?^_`{|}~.def Addr.atext(+c: U32) -> Bool: (c >= 97 && c <= 122 || c >= 65 && c <= 90 || c >= 48 && c <= 57 : U32) || U32.is_eq(c, 33) || (c >= 35 && c <= 39 : U32) || U32.is_eq(c, 42) || U32.is_eq(c, 43) || U32.is_eq(c, 45) || U32.is_eq(c, 47) || U32.is_eq(c, 61) || U32.is_eq(c, 63) || (c >= 94 && c <= 96 : U32) || (c >= 123 && c <= 126 : U32)# Local part chars: atext, ".", and any non-ASCII (RFC 6531 3.3).def Addr.local.ok(s: String) -> Bool: match s: case SNil{}: True{} case SCon{Chr{+c}, t}: (Addr.atext(c) || U32.is_eq(c, 46) || (c >= 128 : U32)) && Addr.local.ok(t)# Domain chars: letters, digits, "-", "." and non-ASCII (a U-label).def Addr.domain.ok(s: String) -> Bool: match s: case SNil{}: True{} case SCon{Chr{+c}, t}: ((c >= 97 && c <= 122 || c >= 65 && c <= 90 || c >= 48 && c <= 57 : U32) || U32.is_eq(c, 45) || U32.is_eq(c, 46) || (c >= 128 : U32)) && Addr.domain.ok(t)# Dots only between other chars.def Addr.dots(+s: String) -> Bool: Bool.not(String.is_empty(s)) && Bool.not(String.starts_with(s, ".")) && Bool.not(String.ends_with(s, ".")) && Bool.not(String.contains(s, ".."))def Addr.parts(parts: List<&2, String>) -> Bool: match parts: case Con{+l, Con{+d, Nil{}}}: Addr.dots(l) && Addr.dots(d) && Addr.local.ok(l) && Addr.domain.ok(d) && Nat.is_le(List.length(&2, U32, M.Utf8.bytes(l)), 64n) case _: False{}# A dot-atom address: no quoting, no route, local part up to 64 octets,# all up to 254.def Addr.ok(+a: String) -> Bool: Nat.is_le(List.length(&2, U32, M.Utf8.bytes(a)), 254n) && Addr.parts(String.split(a, '@'))def Addr.domain.of(parts: List<&2, String>) -> String: match parts: case Con{_, Con{d, _}}: d case _: "localhost"def Addr.domain(a: String) -> String: Addr.domain.of(String.split(a, '@'))def Addr.local(parts: List<&2, String>) -> String: match parts: case Con{l, _}: l case _: ""def Addr.ascii.of(parts: List<&2, String>) -> String: match parts: case Con{l, Con{d, Nil{}}}: l ++ "@" ++ I.Idna.domain(d) case _: ""# The address with its domain as A-labels (RFC 5890).def Addr.ascii(a: String) -> String: Addr.ascii.of(String.split(a, '@'))# Whether a local part has non-ASCII, which only SMTPUTF8 can carry.def Addr.utf8(a: String) -> Bool: Bool.not(I.Idna.ascii(Addr.local(String.split(a, '@'))))# Mailboxes# ---------def Mbox.addr(m: Mbox) -> String: Mbox{_, a} = m adef Mbox.addrs(ms: List<&2, Mbox>) -> List<&2, String>: match ms: case Nil{}: Nil{} case Con{m, t}: Mbox.addr(m) <> Mbox.addrs(t)def Mbox.all.ok(ms: List<&2, Mbox>) -> Bool: match ms: case Nil{}: True{} case Con{m, t}: Addr.ok(Mbox.addr(m)) && Mbox.all.ok(t)# The first bad address of a list, or "" when all are good.def Mbox.bad(ms: List<&2, Mbox>) -> String: match ms: case Nil{}: "" case Con{m, t}: +a = Mbox.addr(m) Bool.pick(String, Addr.ok(a), Mbox.bad(t), Bool.pick(String, String.is_empty(a), "(empty)", a))def Mbox.any.utf8(ms: List<&2, Mbox>) -> Bool: match ms: case Nil{}: False{} case Con{m, t}: Addr.utf8(Mbox.addr(m)) || Mbox.any.utf8(t)def Mbox.ascii(m: Mbox) -> Mbox: Mbox{n, a} = m Mbox{n, Addr.ascii(a)}def Mbox.asciis(ms: List<&2, Mbox>) -> List<&2, Mbox>: match ms: case Nil{}: Nil{} case Con{m, t}: Mbox.ascii(m) <> Mbox.asciis(t)# Reading# -------# Where a list split is: inside quotes, after a backslash in them, inside# angle brackets; the item so far (reversed) and the items (reversed).type Cut is Data: Cut{q: Bool, esc: Bool, a: Bool, cur: String, acc: List<&2, String>}def Mbox.flush(cur: String, acc: List<&2, String>) -> List<&2, String>: String.trim(String.reverse(cur)) <> accdef Mbox.cut.ch(st: Cut, +c: U32) -> Cut: Cut{+q, +esc, +a, +cur, +acc} = st +ch = {SCon{Chr{c}, cur} : String} Bool.pick(Cut, esc, Cut{q, False{}, a, ch, acc}, Bool.pick(Cut, q && U32.is_eq(c, 92), Cut{q, True{}, a, ch, acc}, Bool.pick(Cut, Bool.not(a) && U32.is_eq(c, 34), Cut{Bool.not(q), False{}, a, ch, acc}, Bool.pick(Cut, Bool.not(q) && U32.is_eq(c, 60), Cut{q, False{}, True{}, ch, acc}, Bool.pick(Cut, Bool.not(q) && U32.is_eq(c, 62), Cut{q, False{}, False{}, ch, acc}, Bool.pick(Cut, Bool.not(q) && Bool.not(a) && U32.is_eq(c, 44), Cut{q, False{}, a, "", Mbox.flush(cur, acc)}, Cut{q, False{}, a, ch, acc}))))))def Mbox.cut.go(s: String, st: Cut) -> Cut: match s: case SNil{}: st case SCon{Chr{c}, t}: Mbox.cut.go(t, Mbox.cut.ch(st, c))def Mbox.keep(empty: Bool, x: String, rest: List<&2, String>) -> List<&2, String>: match empty: case True{}: rest case False{}: x <> restdef Mbox.nonempty(xs: List<&2, String>) -> List<&2, String>: match xs: case Nil{}: Nil{} case Con{+x, t}: Mbox.keep(String.is_empty(x), x, Mbox.nonempty(t))def Mbox.cut.fin(st: Cut) -> List<&2, String>: Cut{_, _, _, cur, acc} = st Mbox.nonempty(List.reverse(&2, String, Mbox.flush(cur, acc)))# The items of a comma-separated list; commas in quotes or <...> stay.def Mbox.items(s: String) -> List<&2, String>: Mbox.cut.fin(Mbox.cut.go(s, Cut{False{}, False{}, False{}, "", Nil{}}))def Mbox.unescape(s: String) -> String: match s: case SNil{}: SNil{} case SCon{Chr{92}, SCon{c, t}}: SCon{c, Mbox.unescape(t)} case SCon{c, t}: SCon{c, Mbox.unescape(t)}# A display name as written: its quotes taken off.def Mbox.unquote(+s: String) -> String: Bool.pick(String, String.starts_with(s, "\"") && String.ends_with(s, "\"") && Nat.is_le(2n, String.length(s)), Mbox.unescape(String.drop(String.take(s, Nat.sub(String.length(s), 1n)), 1n)), s)def Mbox.head(xs: List<&2, String>) -> String: match xs: case Nil{}: "" case Con{x, _}: xdef Mbox.angled(+parts: List<&2, String>) -> Mbox: +n = Nat.sub(List.length(&2, String, parts), 1n) Mbox{Mbox.unquote(String.trim(String.join(List.take(&2, String, parts, n), "<"))), String.trim(Mbox.head(String.split(Mbox.head(List.drop(&2, String, parts, n)), '>')))}def Mbox.parse.at(plain: Bool, item: String) -> Mbox: match plain: case True{}: Mbox{"", String.trim(item)} case False{}: Mbox.angled(String.split(item, '<'))# One mailbox: "name <addr>", "<addr>" or "addr".def Mbox.parse(+item: String) -> Mbox: Mbox.parse.at(Bool.not(String.contains(item, "<")), item)def Mbox.parses(xs: List<&2, String>) -> List<&2, Mbox>: match xs: case Nil{}: Nil{} case Con{x, t}: Mbox.parse(x) <> Mbox.parses(t)# The mailboxes of a list as people write it.def Mbox.list(s: String) -> List<&2, Mbox>: Mbox.parses(Mbox.items(s))# Writing# -------def Mbox.atoms(s: String) -> Bool: match s: case SNil{}: True{} case SCon{Chr{+c}, t}: (Addr.atext(c) || U32.is_eq(c, 32)) && Mbox.atoms(t)def Mbox.esc(yes: Bool, c: U32, rest: String) -> String: match yes: case True{}: SCon{'\\', SCon{Chr{c}, rest}} case False{}: SCon{Chr{c}, rest}def Mbox.quote(s: String) -> String: match s: case SNil{}: SNil{} case SCon{Chr{+c}, t}: Mbox.esc(U32.is_eq(c, 34) || U32.is_eq(c, 92), c, Mbox.quote(t))# A display name as a phrase (RFC 5322 3.2.5): atoms as they are, other# printable ASCII as a quoted string, the rest (and anything that could# read as an encoded word) as RFC 2047 words.def Mbox.phrase(+s: String) -> String: Bool.pick(String, Bool.not(M.Mime.printable(s)) || String.contains(s, "=?"), M.Mime.encoded(s), Bool.pick(String, Mbox.atoms(s), s, "\"" ++ Mbox.quote(s) ++ "\""))def Mbox.show.at(none: Bool, name: String, addr: String) -> String: match none: case True{}: addr case False{}: Mbox.phrase(name) ++ " <" ++ addr ++ ">"# A mailbox in a header: "name <addr>", or the bare address.def Mbox.show(m: Mbox) -> String: Mbox{n, a} = m +name = String.trim(T.Text.clean(n)) Mbox.show.at(String.is_empty(name), name, T.Text.clean(a))def Mbox.shows.more(ms: List<&2, Mbox>) -> String: match ms: case Nil{}: SNil{} case Con{m, t}: ",\r\n " ++ Mbox.show(m) ++ Mbox.shows.more(t)# A list header's value: one mailbox per (folded) line.def Mbox.shows(ms: List<&2, Mbox>) -> String: match ms: case Nil{}: SNil{} case Con{m, t}: Mbox.show(m) ++ Mbox.shows.more(t)