~/bend-docscommunity

mime.bend source

mime.bend on the hub · documented module

# MIME and message format: UTF-8 bytes, base64 (RFC 4648), the Date# header (RFC 5322 3.3), encoded words for non-ASCII subjects (RFC 2047)# and a transfer encoding that keeps every line legal (RFC 2045, RFC# 5322 2.1.1). The whole message comes out 7-bit ASCII, so it needs no# 8BITMIME or SMTPUTF8 from the server.import Basedef Word.pick(c: Bool, a: U32, b: U32) -> U32:  match c:    case True{}:      a    case False{}:      b# UTF-8# -----def Utf8.cont(+c: U32, n: Nat) -> U32:  U32.or(128, U32.and(U32.shrn(c, n), 63))# How many bytes a code point takes.def Utf8.size(+c: U32) -> Nat:  Bool.pick(Nat, (c < 128 : U32), 1n,  Bool.pick(Nat, (c < 2048 : U32), 2n,  Bool.pick(Nat, (c < 65536 : U32), 3n, 4n)))def Utf8.put.k(k: Nat, +c: U32, rest: List<&2, U32>) -> List<&2, U32>:  match k:    case 1n:      c <> rest    case 2n:      U32.or(192, U32.shrn(c, 6n)) <> Utf8.cont(c, 0n) <> rest    case 3n:      U32.or(224, U32.shrn(c, 12n)) <> Utf8.cont(c, 6n) <> Utf8.cont(c, 0n)        <> rest    case _:      U32.or(240, U32.shrn(c, 18n)) <> Utf8.cont(c, 12n) <> Utf8.cont(c, 6n)        <> Utf8.cont(c, 0n) <> rest# One code point's bytes, put in front of rest.def Utf8.put(+c: U32, rest: List<&2, U32>) -> List<&2, U32>:  Utf8.put.k(Utf8.size(c), c, rest)def Utf8.bytes(s: String) -> List<&2, U32>:  match s:    case SNil{}:      Nil{}    case SCon{Chr{c}, t}:      Utf8.put(c, Utf8.bytes(t))# Base64# ------# The base64 digit for 0..63.def B64.ch(+i: U32) -> Char:  Chr{Word.pick((i < 26 : U32), (i + 65 : U32),    Word.pick((i < 52 : U32), (i + 71 : U32),    Word.pick((i < 62 : U32), (i - 4 : U32),    Word.pick(U32.is_eq(i, 62), 43, 47))))}def B64.at(+x: U32, n: Nat) -> Char:  B64.ch(U32.and(U32.shrn(x, n), 63))# A group of 4 digits, the last `pad` of them "=".def B64.quad(+x: U32, pad: Nat, rest: String) -> String:  match pad:    case 0n:      SCon{B64.at(x, 18n), SCon{B64.at(x, 12n), SCon{B64.at(x, 6n),        SCon{B64.at(x, 0n), rest}}}}    case 1n:      SCon{B64.at(x, 18n), SCon{B64.at(x, 12n), SCon{B64.at(x, 6n),        SCon{'=', rest}}}}    case _:      SCon{B64.at(x, 18n), SCon{B64.at(x, 12n), SCon{'=', SCon{'=', rest}}}}def B64.word(+a: U32, +b: U32, +c: U32) -> U32:  U32.or(U32.shln(a, 16n), U32.or(U32.shln(b, 8n), c))def B64.onto(acc: String, tail: String) -> String:  match acc:    case SNil{}:      tail    case SCon{c, t}:      B64.onto(t, SCon{c, tail})# The 4 digits of a group, reversed, on acc; pad of them "=".def B64.quad.rev(+x: U32, pad: Nat, acc: String) -> String:  match pad:    case 0n:      SCon{B64.at(x, 0n), SCon{B64.at(x, 6n), SCon{B64.at(x, 12n),        SCon{B64.at(x, 18n), acc}}}}    case 1n:      SCon{'=', SCon{B64.at(x, 6n), SCon{B64.at(x, 12n), SCon{B64.at(x, 18n), acc}}}}    case _:      SCon{'=', SCon{'=', SCon{B64.at(x, 12n), SCon{B64.at(x, 18n), acc}}}}# A CRLF (reversed) before the group when `wrap` groups fill the line.def B64.brk(full: Bool, acc: String) -> String:  match full:    case True{}:      SCon{Chr{10}, SCon{Chr{13}, acc}}    case False{}:      acc# The digits of bs, in front of tail; k groups are on the current line,# and a line takes `wrap` groups (0: one line). acc holds the digits so# far, reversed: one tail call per group, then one pass onto the tail.def B64.go(bs: List<&2, U32>, +k: U32, +wrap: U32, acc: String, tail: String) ->  String:  match bs:    case Nil{}:      B64.onto(acc, tail)    case Con{a, Con{b, Con{c, rest}}}:      +full = (wrap > 0 && k >= wrap : U32)      B64.go(rest, Word.pick(full, 1, (k + 1 : U32)), wrap,        B64.quad.rev(B64.word(a, b, c), 0n, B64.brk(full, acc)), tail)    case Con{a, Con{b, Nil{}}}:      B64.onto(B64.quad.rev(B64.word(a, b, 0), 1n,        B64.brk((wrap > 0 && k >= wrap : U32), acc)), tail)    case Con{a, Nil{}}:      B64.onto(B64.quad.rev(B64.word(a, 0, 0), 2n,        B64.brk((wrap > 0 && k >= wrap : U32), acc)), tail)def B64.encode(bs: List<&2, U32>) -> String:  B64.go(bs, 0, 0, SNil{}, SNil{})# Base64 in lines of 76 digits (19 groups), as MIME bodies need, then# tail.def B64.lines(bs: List<&2, U32>, tail: String) -> String:  B64.go(bs, 0, 19, SNil{}, tail)def B64.text(s: String) -> String:  B64.encode(Utf8.bytes(s))# Date# ----def Date.pad(+n: U32) -> String:  Bool.pick(String, (n < 10 : U32), "0" ++ U32.show(n), U32.show(n))def Date.day(+w: U32) -> String:  Bool.pick(String, U32.is_eq(w, 0), "Sun",  Bool.pick(String, U32.is_eq(w, 1), "Mon",  Bool.pick(String, U32.is_eq(w, 2), "Tue",  Bool.pick(String, U32.is_eq(w, 3), "Wed",  Bool.pick(String, U32.is_eq(w, 4), "Thu",  Bool.pick(String, U32.is_eq(w, 5), "Fri", "Sat"))))))def Date.month(+m: U32) -> String:  Bool.pick(String, U32.is_eq(m, 1), "Jan",  Bool.pick(String, U32.is_eq(m, 2), "Feb",  Bool.pick(String, U32.is_eq(m, 3), "Mar",  Bool.pick(String, U32.is_eq(m, 4), "Apr",  Bool.pick(String, U32.is_eq(m, 5), "May",  Bool.pick(String, U32.is_eq(m, 6), "Jun",  Bool.pick(String, U32.is_eq(m, 7), "Jul",  Bool.pick(String, U32.is_eq(m, 8), "Aug",  Bool.pick(String, U32.is_eq(m, 9), "Sep",  Bool.pick(String, U32.is_eq(m, 10), "Oct",  Bool.pick(String, U32.is_eq(m, 11), "Nov", "Dec")))))))))))# Year, month and day of a day count since 1970-01-01 (H. Hinnant's# civil_from_days, for days >= 0), as "DD Mon YYYY".def Date.civil(+days: U32) -> String:  +z   = (days + 719468 : U32)  +era = (z / 146097 : U32)  +doe = (z - era * 146097 : U32)  +yoe = ((doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 : U32)  +doy = (doe - (365 * yoe + yoe / 4 - yoe / 100) : U32)  +mp  = ((5 * doy + 2) / 153 : U32)  +d   = (doy - (153 * mp + 2) / 5 + 1 : U32)  +m   = Word.pick((mp < 10 : U32), (mp + 3 : U32), (mp - 9 : U32))  +y   = (yoe + era * 400 + Word.pick((m <= 2 : U32), 1, 0) : U32)  Date.pad(d) ++ " " ++ Date.month(m) ++ " " ++ U32.show(y)# The Date header's value for t seconds since 1970, in UTC.def Date.fmt(+t: U32) -> String:  +days = (t / 86400 : U32)  +secs = (t % 86400 : U32)  Date.day(((days + 4) % 7 : U32)) ++ ", " ++ Date.civil(days) ++ " "    ++ Date.pad((secs / 3600 : U32)) ++ ":"    ++ Date.pad(((secs % 3600) / 60 : U32)) ++ ":"    ++ Date.pad((secs % 60 : U32)) ++ " +0000"# Subject (RFC 2047)# -------# Whether every char is printable ASCII (space to tilde).def Mime.printable(s: String) -> Bool:  match s:    case SNil{}:      True{}    case SCon{Chr{+c}, t}:      (c >= 32 && c <= 126 : U32) && Mime.printable(t)# Encoded words built so far, the bytes of the current one and its size.type Words is Data:  Words{out: String, cur: List<&2, U32>, n: U32}def Mime.word(cur: List<&2, U32>) -> String:  "=?UTF-8?B?" ++ B64.encode(cur) ++ "?="# 39 bytes make 52 digits: "Subject: " plus one word is 73 columns.def Mime.add.at(full: Bool, out: String, cur: List<&2, U32>, +n: U32,  bc: List<&2, U32>, +k: U32) -> Words:  match full:    case True{}:      Words{out ++ Mime.word(cur) ++ "\r\n ", bc, k}    case False{}:      Words{out, List.append(&2, U32, cur, bc), (n + k : U32)}def Mime.add(w: Words, +c: U32) -> Words:  Words{out, cur, +n} = w  +k = U32.from_nat(Utf8.size(c))  Mime.add.at((n > 0 && n + k > 39 : U32), out, cur, n, Utf8.put(c, Nil{}), k)def Mime.words(s: String, w: Words) -> String:  match s:    case SNil{}:      Words{out, cur, _} = w      out ++ Mime.word(cur)    case SCon{Chr{c}, t}:      Mime.words(t, Mime.add(w, c))# s as RFC 2047 encoded words of at most 39 bytes each, folded.def Mime.encoded(s: String) -> String:  Mime.words(s, Words{"", Nil{}, 0})def Mime.subject.at(plain: Bool, s: String) -> String:  match plain:    case True{}:      s    case False{}:      Mime.encoded(s)# A Subject value: as is when it is printable ASCII that fits one line# and cannot read as an encoded word, else encoded words, folded.def Mime.subject(+s: String) -> String:  Mime.subject.at(Mime.printable(s) && Bool.not(String.contains(s, "=?"))    && Nat.is_le(String.length(s), 69n), s)# Body# ----# Line breaks as CRLF, bare CRs dropped.def Mime.crlf(s: String) -> String:  match s:    case SNil{}:      SNil{}    case SCon{Chr{13}, t}:      Mime.crlf(t)    case SCon{Chr{10}, t}:      SCon{Chr{13}, SCon{Chr{10}, Mime.crlf(t)}}    case SCon{c, t}:      SCon{c, Mime.crlf(t)}# Whether the body can go as 7bit: ASCII without NUL, and no line past# 998 chars (RFC 5322 2.1.1); col counts the current line.def Mime.seven(s: String, +col: U32) -> Bool:  match s:    case SNil{}:      True{}    case SCon{Chr{10}, t}:      Mime.seven(t, 0)    case SCon{Chr{13}, t}:      Mime.seven(t, col)    case SCon{Chr{+c}, t}:      (c >= 1 && c <= 127 && col < 998 : U32) && Mime.seven(t, (col + 1 : U32))# The body's transfer encoding and its text: 7bit as written, else# base64 of its UTF-8 (CRLF line breaks first, RFC 2045 6.8).type Body is Data:  Body{encoding: String, text: String}def Mime.body.at(seven: Bool, s: String) -> Body:  match seven:    case True{}:      Body{"7bit", Mime.crlf(s)}    case False{}:      Body{"base64", B64.lines(Utf8.bytes(Mime.crlf(s)), "\r\n")}# Text goes 7bit only if it cannot hold a part boundary (Mime.boundary).def Mime.body(+s: String) -> Body:  Mime.body.at(Mime.seven(s, 0) && Bool.not(String.contains(s, "=_bend_")), s)# Base64 decoding# ---------------# A base64 digit's value, or 64 for anything else.def B64.val(+c: U32) -> U32:  Word.pick((c >= 65 && c <= 90 : U32), (c - 65 : U32),  Word.pick((c >= 97 && c <= 122 : U32), (c - 71 : U32),  Word.pick((c >= 48 && c <= 57 : U32), (c + 4 : U32),  Word.pick(U32.is_eq(c, 43), 62, Word.pick(U32.is_eq(c, 47), 63, 64)))))def B64.keep(ok: Bool, v: U32, rest: List<&2, U32>) -> List<&2, U32>:  match ok:    case True{}:      v <> rest    case False{}:      restdef B64.vals(s: String) -> List<&2, U32>:  match s:    case SNil{}:      Nil{}    case SCon{Chr{c}, t}:      +v = B64.val(c)      B64.keep((v < 64 : U32), v, B64.vals(t))def B64.byte(+x: U32, n: Nat) -> U32:  U32.and(U32.shrn(x, n), 255)def B64.group(+a: U32, +b: U32, +c: U32, +d: U32) -> U32:  U32.or(U32.or(U32.shln(a, 18n), U32.shln(b, 12n)), U32.or(U32.shln(c, 6n), d))def B64.bytes(vs: List<&2, U32>) -> List<&2, U32>:  match vs:    case Con{a, Con{b, Con{c, Con{d, rest}}}}:      +x = B64.group(a, b, c, d)      B64.byte(x, 16n) <> B64.byte(x, 8n) <> B64.byte(x, 0n) <> B64.bytes(rest)    case Con{a, Con{b, Con{c, Nil{}}}}:      +x = B64.group(a, b, c, 0)      [B64.byte(x, 16n), B64.byte(x, 8n)]    case Con{a, Con{b, Nil{}}}:      [B64.byte(B64.group(a, b, 0, 0), 16n)]    case _:      Nil{}# The bytes of base64 text; padding and anything off the alphabet skipped.def B64.decode(s: String) -> List<&2, U32>:  B64.bytes(B64.vals(s))# Bytes as text: ASCII as is, any other byte as "?" (for error messages).def B64.ascii(bs: List<&2, U32>) -> String:  match bs:    case Nil{}:      SNil{}    case Con{+b, t}:      SCon{Chr{Word.pick((b < 128 : U32), b, 63)}, B64.ascii(t)}# Parts# -----# An attachment: its file name, content type and bytes.type Part is Data:  Part{name: String, ctype: String, data: List<&2, U32>}def Mime.ext.is(+name: String, e: String) -> Bool:  String.ends_with(String.to_lower(name), e)# A content type from the file name's extension (application/octet-stream# when unknown). message/rfc822 is never used: it may not be base64.def Mime.ctype(+n: String) -> String:  Bool.pick(String, Mime.ext.is(n, ".pdf"), "application/pdf",  Bool.pick(String, Mime.ext.is(n, ".png"), "image/png",  Bool.pick(String, Mime.ext.is(n, ".jpg") || Mime.ext.is(n, ".jpeg"), "image/jpeg",  Bool.pick(String, Mime.ext.is(n, ".gif"), "image/gif",  Bool.pick(String, Mime.ext.is(n, ".webp"), "image/webp",  Bool.pick(String, Mime.ext.is(n, ".svg"), "image/svg+xml",  Bool.pick(String, Mime.ext.is(n, ".txt"), "text/plain",  Bool.pick(String, Mime.ext.is(n, ".md"), "text/markdown",  Bool.pick(String, Mime.ext.is(n, ".csv"), "text/csv",  Bool.pick(String, Mime.ext.is(n, ".html") || Mime.ext.is(n, ".htm"), "text/html",  Bool.pick(String, Mime.ext.is(n, ".json"), "application/json",  Bool.pick(String, Mime.ext.is(n, ".xml"), "application/xml",  Bool.pick(String, Mime.ext.is(n, ".zip"), "application/zip",  Bool.pick(String, Mime.ext.is(n, ".gz"), "application/gzip",  Bool.pick(String, Mime.ext.is(n, ".mp3"), "audio/mpeg",  Bool.pick(String, Mime.ext.is(n, ".wav"), "audio/wav",  Bool.pick(String, Mime.ext.is(n, ".mp4"), "video/mp4",  Bool.pick(String, Mime.ext.is(n, ".docx"),    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",  Bool.pick(String, Mime.ext.is(n, ".xlsx"),    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",  Bool.pick(String, Mime.ext.is(n, ".pptx"),    "application/vnd.openxmlformats-officedocument.presentationml.presentation",  Bool.pick(String, Mime.ext.is(n, ".odt"), "application/vnd.oasis.opendocument.text",    "application/octet-stream")))))))))))))))))))))# attr-char (RFC 2231 7): ALPHA DIGIT !#$&+-.^_`|~.def Mime.attr(+c: U32) -> Bool:  (c >= 97 && c <= 122 || c >= 65 && c <= 90 || c >= 48 && c <= 57 : U32)    || U32.is_eq(c, 33) || U32.is_eq(c, 35) || U32.is_eq(c, 36)    || U32.is_eq(c, 38) || U32.is_eq(c, 43) || U32.is_eq(c, 45)    || U32.is_eq(c, 46) || U32.is_eq(c, 94) || U32.is_eq(c, 95)    || U32.is_eq(c, 96) || U32.is_eq(c, 124) || U32.is_eq(c, 126)def Mime.hex(+d: U32) -> Char:  Chr{Word.pick((d < 10 : U32), (d + 48 : U32), (d + 55 : U32))}def Mime.pct.at(keep: Bool, +b: U32, rest: String) -> String:  match keep:    case True{}:      SCon{Chr{b}, rest}    case False{}:      SCon{'%', SCon{Mime.hex(U32.shrn(b, 4n)), SCon{Mime.hex(U32.and(b, 15)), rest}}}# Bytes percent-encoded outside attr-char (RFC 2231 4).def Mime.pct(bs: List<&2, U32>) -> String:  match bs:    case Nil{}:      SNil{}    case Con{+b, t}:      Mime.pct.at(Mime.attr(b), b, Mime.pct(t))def Mime.safe(s: String) -> Bool:  match s:    case SNil{}:      True{}    case SCon{Chr{+c}, t}:      (c >= 32 && c <= 126 : U32) && Bool.not(U32.is_eq(c, 34))        && Bool.not(U32.is_eq(c, 92)) && Mime.safe(t)def Mime.param.at(safe: Bool, +key: String, +n: String) -> String:  match safe:    case True{}:      ";\r\n " ++ key ++ "=\"" ++ n ++ "\""    case False{}:      ";\r\n " ++ key ++ "*=UTF-8''" ++ Mime.pct(Utf8.bytes(n))# A file name parameter: quoted when the name is safe ASCII, else only# in RFC 2231's extended form (as Python's email package writes it: a# second, ASCII form would be read first by some parsers).def Mime.param(+key: String, +n: String) -> String:  Mime.param.at(Mime.safe(n), key, n)# Entities# --------# A text leaf: its headers, a blank line, its body.def Mime.leaf.with(ctype: String, b: Body) -> String:  Body{enc, text} = b  "Content-Type: " ++ ctype ++ "; charset=UTF-8\r\n"    ++ "Content-Transfer-Encoding: " ++ enc ++ "\r\n\r\n" ++ textdef Mime.leaf(ctype: String, +s: String) -> String:  Mime.leaf.with(ctype, Mime.body(s))# An attachment leaf, always base64, in front of rest.def Mime.attach(p: Part, rest: String) -> String:  Part{+name, ctype, data} = p  "Content-Type: " ++ ctype ++ Mime.param("name", name) ++ "\r\n"    ++ "Content-Disposition: attachment" ++ Mime.param("filename", name) ++ "\r\n"    ++ "Content-Transfer-Encoding: base64\r\n\r\n" ++ B64.lines(data, "\r\n" ++ rest)def Mime.parts(+b: String, ps: List<&2, String>) -> String:  match ps:    case Nil{}:      "\r\n--" ++ b ++ "--\r\n"    case Con{p, t}:      "\r\n--" ++ b ++ "\r\n" ++ p ++ Mime.parts(b, t)def Mime.preamble() -> String:  "This is a multi-part message in MIME format."# A multipart entity (RFC 2046 5.1): a boundary that no part can hold,# then each part after a delimiter line, then the close delimiter.def Mime.multi(sub: String, +b: String, ps: List<&2, String>) -> String:  "Content-Type: multipart/" ++ sub ++ ";\r\n boundary=\"" ++ b ++ "\"\r\n\r\n"    ++ Mime.preamble() ++ Mime.parts(b, ps)# The files as parts, each after its delimiter, then the close one.def Mime.attaches(fs: List<&2, Part>, +b: String) -> String:  match fs:    case Nil{}:      "\r\n--" ++ b ++ "--\r\n"    case Con{f, t}:      "\r\n--" ++ b ++ "\r\n" ++ Mime.attach(f, Mime.attaches(t, b))def Mime.alt.at(none: Bool, text: String, html: String, +b: String) -> String:  match none:    case True{}:      Mime.leaf("text/plain", text)    case False{}:      Mime.multi("alternative", b ++ "_a", [Mime.leaf("text/plain", text),        Mime.leaf("text/html", html)])def Mime.mixed.at(none: Bool, alt: String, files: List<&2, Part>, +b: String) ->  String:  match none:    case True{}:      alt    case False{}:      +bm = b ++ "_m"      "Content-Type: multipart/mixed;\r\n boundary=\"" ++ bm ++ "\"\r\n\r\n"        ++ Mime.preamble() ++ "\r\n--" ++ bm ++ "\r\n" ++ alt        ++ Mime.attaches(files, bm)# The message's entity: text alone; text and HTML as alternatives# (plain first, RFC 2046 5.1.4); either one with files as mixed. b is# the boundary stem: base64 and the 7bit texts (Mime.body) cannot hold it.def Mime.entity(text: String, +html: String, +files: List<&2, Part>, +b: String) ->  String:  Mime.mixed.at(List.is_empty(&2, Part, files),    Mime.alt.at(String.is_empty(html), text, html, b), files, b)# A boundary stem from random words: "=_bend_" and hex.def Mime.boundary(+hex: String) -> String:  "=_bend_" ++ hex