// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// The two encodings RFC 2047 §4 allows inside an encoded word, named for the
/// letter that announces them in the header itself.
pub(all) enum WordEncoding {
Base64
QuotedPrintable
} derive(Eq)
///|
pub extend WordEncoding with Eq::{not_equal, equal}
///|
fn WordEncoding::letter(self : WordEncoding) -> Char {
match self {
WordEncoding::Base64 => 'B'
WordEncoding::QuotedPrintable => 'Q'
}
}
///|
/// The safe set of the Q encoding: alphanumerics and the five punctuation marks
/// RFC 2047 §4.2 kept, since they stand for nothing inside a header. Space
/// becomes `_`; everything else becomes `=XY`.
fn q_word_encode(data : BytesView) -> String {
let out = StringBuilder()
for b in data {
let c = b.to_char()
if is_alpha(c) || is_digit(c) {
out.write_char(c)
} else if c == ' ' {
out.write_char('_')
} else if c == '-' || c == '!' || c == '*' || c == '+' || c == '/' {
out.write_char(c)
} else {
let n = byte_code(b)
out.write_char('=')
out.write_char(hex_digit(n / 16))
out.write_char(hex_digit(n % 16))
}
}
out.to_string()
}
///|
/// One encoded word, however long it comes out. Callers answerable to the 75
/// character limit RFC 2047 puts on a word use `encode_words` instead.
fn word_body(data : BytesView, encoding : WordEncoding) -> String {
match encoding {
WordEncoding::Base64 => @base64.encode(data)
WordEncoding::QuotedPrintable => q_word_encode(data)
}
}
///|
fn wrap_word(
data : BytesView,
charset : StringView,
encoding : WordEncoding,
) -> String {
"=?" +
charset.to_owned() +
"?" +
encoding.letter().to_string() +
"?" +
word_body(data, encoding) +
"?="
}
///|
/// One encoded word, however long it comes out.
///
/// ```mbt check
/// test {
/// let word = @moonmime.encode_word("Café") catch { _ => "" }
/// inspect(@moonmime.decode_words(word[:]), content="Café")
/// }
/// ```
pub fn encode_word(
text : StringView,
charset~ : String = "utf-8",
encoding~ : WordEncoding = WordEncoding::Base64,
charsets? : Map[String, &Charset],
) -> String raise Refused {
let data = encode_charset(name=charset, text~, charsets?)
wrap_word(data[:], charset[:], encoding)
}
///|
/// How many octets one word may carry. `=?charset?B?` and `?=` are chrome and
/// count against the 75 too, so what is left is what the encoded octets spend.
fn word_capacity(charset : StringView, encoding : WordEncoding) -> Int32 {
let budget = 75 - (7 + charset.length())
let room = if budget < 1 { 1 } else { budget }
match encoding {
WordEncoding::Base64 => {
let per = room / 4 * 3
if per < 3 {
3
} else {
per
}
}
WordEncoding::QuotedPrintable =>
// One octet costs up to three characters; stepping by the budget rather
// than by threes keeps mostly-safe text readable.
if room < 3 {
1
} else {
room
}
}
}
///|
/// Whether `b` is a UTF-8 continuation octet (`10xxxxxx`). A slice that ends on
/// one of these has cut a multi-octet character in half, which RFC 2047 §5
/// forbids, so `word_slices` backs the boundary up until it lands on a lead
/// octet.
fn is_continuation_byte(b : Byte) -> Bool {
let n = byte_code(b)
n >= 0x80 && n <= 0xBF
}
///|
/// Split the octets into pieces each small enough for one word, never letting
/// the Q form run past what the budget allows. UTF-8 pieces are cut on a
/// character boundary so no encoded word carries half a character.
fn word_slices(
data : BytesView,
charset : StringView,
encoding : WordEncoding,
) -> Array[Bytes] {
let step = word_capacity(charset, encoding)
let utf8 = {
let name = lower(charset)
name == "utf-8" || name == "utf8"
}
let slices : Array[Bytes] = []
match encoding {
WordEncoding::Base64 => {
let mut at = 0
while at < data.length() {
let mut stop = at + step
if stop > data.length() {
stop = data.length()
}
if utf8 {
while stop < data.length() &&
stop > at &&
is_continuation_byte(data.at(stop)) {
stop = stop - 1
}
}
slices.push(data[at:stop].to_owned())
at = stop
}
}
WordEncoding::QuotedPrintable => {
let mut start = 0
while start < data.length() {
let mut stop = start
let mut width = 0
while stop < data.length() {
let piece = q_word_encode(data[stop:stop + 1]).length()
if width + piece > step && stop > start {
break
}
width = width + piece
stop = stop + 1
}
if stop == start {
stop = if start + 1 > data.length() {
data.length()
} else {
start + 1
}
}
slices.push(data[start:stop].to_owned())
start = stop
}
}
}
slices
}
///|
/// A header value written as encoded words, splitting whenever the next word
/// would run past `max`.
///
/// Text that is already printable US-ASCII goes back unchanged: it needs no
/// encoding, and writing it plainly is what keeps `Subject: Hello` readable in
/// a raw view.
pub fn encode_words(
text : StringView,
charset~ : String = "utf-8",
encoding~ : WordEncoding = WordEncoding::Base64,
column~ : Int32 = 0,
max~ : Int32 = 76,
charsets? : Map[String, &Charset],
) -> String raise Refused {
if text.is_empty() || is_ascii_printable(text) {
return text.to_owned()
}
let data = encode_charset(name=charset, text~, charsets?)
let slices = word_slices(data[:], charset[:], encoding)
let out = StringBuilder()
let mut col = column
let mut first = true
for slice in slices {
let word = wrap_word(slice[:], charset[:], encoding)
if first {
first = false
} else if col + 1 + word.length() <= max {
out.write_char(' ')
col = col + 1
} else {
out.write_string("\r\n ")
col = 1
}
out.write_string(word)
col = col + word.length()
}
out.to_string()
}
///|
/// An encoded word seen in `chars` starting at `at`: its charset, its encoding,
/// the octets its body held, and where the next character begins. `None` when
/// the shape is not an encoded word, which is how a stray `=?` inside ordinary
/// text stays ordinary text.
fn take_word(
chars : ArrayView[Char],
at : Int32,
) -> (String, WordEncoding, Bytes, Int32)? {
let n = chars.length()
if at + 2 > n {
return None
}
if chars[at] != '=' || chars[at + 1] != '?' {
return None
}
let name = StringBuilder()
let mut i = at + 2
while i < n {
let c = chars[i]
if c == '?' {
break
}
if is_wsp(c) {
return None
}
name.write_char(c)
i = i + 1
}
let charset = name.to_string()
if charset.is_empty() {
return None
}
if i + 3 >= n {
return None
}
let encoding = match chars[i + 1] {
'B' | 'b' => Some(WordEncoding::Base64)
'Q' | 'q' => Some(WordEncoding::QuotedPrintable)
_ => None
}
if encoding is None {
return None
}
let encoding = encoding.unwrap()
if chars[i + 2] != '?' {
return None
}
let body = StringBuilder()
let mut j = i + 3
while j + 1 < n {
if chars[j] == '?' && chars[j + 1] == '=' {
break
}
body.write_char(chars[j])
j = j + 1
}
if j + 1 >= n {
return None
}
let text = body.to_string()
let octets = match encoding {
WordEncoding::Base64 =>
Some(@base64.decode(text[:], padding=true)) catch {
_ => None
}
WordEncoding::QuotedPrintable =>
try {
let mut replaced = ""
for c in text {
replaced = replaced + (if c == '_' { " " } else { c.to_string() })
}
Some(@qp.decode(@utf8.encode(replaced)[:]))
} catch {
_ => None
}
}
if octets is None {
return None
}
Some((charset, encoding, octets.unwrap(), j + 2))
}
///|
/// Undo what `encode_words` did, and what every other mail library did too:
/// encoded words are the one corner of a header everybody writes the same way.
///
/// Whitespace between two adjacent encoded words disappears, because that was
/// folding and not content; whitespace next to ordinary text stays put.
///
/// ```mbt check
/// test {
/// inspect(@moonmime.decode_words("=?utf-8?B?Q2Fmw6k=?="), content="Café")
/// }
/// ```
pub fn decode_words(
value : StringView,
charsets? : Map[String, &Charset],
) -> String {
let chars = value.to_owned().to_array()
let n = chars.length()
let out = StringBuilder()
let mut pending = 0
let mut after_word = false
let mut i = 0
while i < n {
let c = chars[i]
if c == '=' && i + 1 < n && chars[i + 1] == '?' {
match take_word(chars[:], i) {
Some((charset, _encoding, octets, stop)) => {
if !after_word {
for _k in 0.. latin1_text(octets[:])
}
out.write_string(text)
after_word = true
i = stop
continue
}
None => {
write_spaces(out, pending)
pending = 0
out.write_char(c)
after_word = false
i = i + 1
continue
}
}
}
if is_wsp(c) || c == '\r' || c == '\n' {
pending = pending + 1
i = i + 1
continue
}
write_spaces(out, pending)
pending = 0
out.write_char(c)
after_word = false
i = i + 1
}
write_spaces(out, pending)
out.to_string()
}
///|
fn write_spaces(out : StringBuilder, count : Int32) -> Unit {
let mut left = count
while left > 0 {
out.write_char(' ')
left = left - 1
}
}