//// This module implements functions to render a sendr message into the //// internet message format as specified in //// [RFC 5322](https://tools.ietf.org/html/rfc5322) and updated by //// [RFC 6854](https://tools.ietf.org/html/rfc6854). import gleam/bit_array import gleam/bool import gleam/crypto import gleam/int import gleam/list.{Continue, Stop} import gleam/option.{type Option, None, Some} import gleam/order import gleam/pair import gleam/result import gleam/string import gleam/time/calendar.{ type Month, April, August, Date, December, February, January, July, June, March, May, November, October, September, TimeOfDay, } import gleam/time/duration.{type Duration} import gleam/time/timestamp.{type Timestamp} import internal/encoder import internal/encoder/encoding.{ type EncoderError, type Encoding, type EncodingMode, } import sendr/message.{type Message} import sendr/message/attachment.{ type Attachment, AttachedAttachment, InlinedAttachment, } import sendr/message/body.{type Body} import sendr/message/mailbox.{type Mailbox} const structured_encoders: List(Encoding) = [ encoding.Text(encoding.Structured), encoding.QuotedPrintable(encoding.Rfc2047), encoding.Base64, ] const unstructured_encoders: List(Encoding) = [ encoding.Text(encoding.Unstructured), encoding.QuotedPrintable(encoding.Rfc2047), encoding.Base64, ] const body_encoders: List(Encoding) = [ encoding.Bit(7), encoding.Bit(8), encoding.QuotedPrintable(encoding.Rfc2045), encoding.Base64, ] // // Constants // const preferred_line_size: Int = 78 const maximum_line_size: Int = 998 // // Types // /// Errors that can occur when encoding header field bodies. pub type FieldBodyError { /// The date value is invalid. InvalidDate(#(Timestamp, Duration)) /// The email address is invalid. InvalidEmailAddress(String) } /// Errors that can occur during message rendering. pub type RenderError { /// The header field name is invalid (e.g., contains spaces or special characters). InvalidHeaderFieldName(String) /// The header field body is invalid and can't be encoded. InvalidHeaderFieldBody(FieldBodyError) /// An invalid encoding was specified for the context (header/body) it is used in. InvalidEncoding(encoding.Encoding) /// No suitable encoder could be found for the given input and constraints. NoUsableEncodingFound(List(Encoding)) /// Encoding failed due to an encoder error. EncodingFailed(EncoderError) } type BodyPart { BodyPart(headers: List(#(String, List(String))), body: List(String)) } /// Render a sendr Message into an Internet Message format string. /// /// This function converts a `sendr/message.Message` into a string /// conforming to [RFC 5322](https://tools.ietf.org/html/rfc5322) /// and updated by [RFC 6854](https://tools.ietf.org/html/rfc6854). /// /// The rendered message includes: /// - `Date` header (automatically generated from the current system time) /// - `From`, `Reply-To`, `To`, `Cc` headers (if set on the message) /// - `Subject` header (if set on the message) /// - `Message-ID` header (generated from the sender's address) /// - `MIME-Version: 1.0` header /// - Message body (text and/or HTML) with appropriate MIME encoding /// - Attachments (if any), wrapped in multipart/mixed or multipart/related pub fn encode( mail message: Message, with mode: EncodingMode, ) -> Result(List(String), RenderError) { let date = #(timestamp.system_time(), calendar.utc_offset) let message_id = option.or(message.from, Some(mailbox.new("", "@localhost"))) Ok([]) |> append_strings(date, date_header("Date", _)) |> maybe_append_strings(message.from, mailbox_header("From", _, mode)) |> maybe_append_strings(message.reply_to, mailbox_list_header( "Reply-To", _, mode, )) |> maybe_append_strings(message.to, mailbox_list_header("To", _, mode)) |> maybe_append_strings(message.cc, mailbox_list_header("Cc", _, mode)) |> maybe_append_strings(message.subject, unstructured_header( "Subject", _, mode, )) |> maybe_append_strings(message_id, message_id_header("Message-ID", _, mode)) |> append_strings("1.0", unstructured_header("MIME-Version", _, mode)) |> append_strings(#(message.body, message.attachments), body(_, mode)) |> result.map(list.reverse) } // // Date header encoding // fn date_header( field_name: String, field_body: #(Timestamp, Duration), ) -> Result(List(String), RenderError) { use field_name <- result.map(encode_field_name(field_name)) let #(timestamp, offset) = field_body let #(Date(day:, month:, year:), TimeOfDay(hours:, minutes:, seconds:, ..)) = timestamp.to_calendar(timestamp, calendar.utc_offset) let weekday = weekday_to_string(timestamp) let date = string.join([pad(day), month_to_string(month), int.to_string(year)], " ") let time = string.join([pad(hours), pad(minutes), pad(seconds)], ":") let zone = duration_to_string(offset) prepend_field_name(field_name, [ weekday <> ", " <> date <> " " <> time <> " " <> zone, ]) } fn weekday_to_string(timestamp: Timestamp) -> String { // Unix epoch is Thursday 1 January 1970, 00:00:00 UTC. There are 86400 seconds // in a day, and Thursday is the 4 day in the week (starting counting at 0). let #(seconds, _milliseconds) = timestamp.to_unix_seconds_and_nanoseconds(timestamp) let weekday = { seconds / 86_400 + 4 } % 7 case weekday { 0 -> "Sun" 1 -> "Mon" 2 -> "Tue" 3 -> "Wed" 4 -> "Thu" 5 -> "Fri" 6 -> "Sat" // nolint: avoid_panic -- fallback body is unreachable due to modulo _ -> panic as { "Unexpectely received " <> int.to_string(weekday) } } } fn month_to_string(month: Month) -> String { case month { January -> "Jan" February -> "Feb" March -> "Mar" April -> "Apr" May -> "May" June -> "Jun" July -> "Jul" August -> "Aug" September -> "Sep" October -> "Oct" November -> "Nov" December -> "Dec" } } fn duration_to_string(duration: Duration) -> String { let #(seconds, _milliseconds) = duration.to_seconds_and_nanoseconds(duration) let hours = int.absolute_value(seconds / 3600) let minutes = int.absolute_value(seconds / 60 % 60) case seconds >= 0 { True -> "+" <> pad(hours) <> pad(minutes) False -> "-" <> pad(hours) <> pad(minutes) } } fn pad(value: Int) -> String { string.pad_start(int.to_string(value), 2, "0") } // // Mailbox header encoding // fn mailbox_header( field_name: String, field_body: Mailbox, mode: EncodingMode, ) -> Result(List(String), RenderError) { mailbox_list_header(field_name, [field_body], mode) } fn mailbox_list_header( field_name: String, field_body: List(Mailbox), mode: EncodingMode, ) -> Result(List(String), RenderError) { use field_name <- result.try(encode_field_name(field_name)) use encoded_mailboxes <- result.map(encode_mailbox_list( field_body, string.byte_size(field_name) + 2, mode, )) prepend_field_name(field_name, encoded_mailboxes) } fn encode_mailbox_list( mailboxes: List(Mailbox), count: Int, mode: encoding.EncodingMode, ) -> Result(List(String), RenderError) { let last_mailbox_index = list.length(mailboxes) - 1 mailboxes |> try_index_fold(#(count, []), fn(accumulator, mailbox, index) { let #(count, lines) = accumulator let indent = case index { 0 -> "" _ -> " " } let separator = case index { index if index < last_mailbox_index -> "," _ -> "" } encode_mailbox(mailbox, count + 1, mode, indent, separator) |> result.map(fn(mailbox_lines) { case mailbox_lines, lines { [], lines -> lines mailbox_lines, [] -> mailbox_lines ["", ..other_mailbox_lines], [last_line, ..other_lines] -> list.append(other_mailbox_lines, [last_line, ..other_lines]) [first_mailbox_line, ..other_mailbox_lines], [last_line, ..other_lines] -> list.append(other_mailbox_lines, [ last_line <> first_mailbox_line, ..other_lines ]) } }) |> result.map(fn(lines) { case lines { [] -> #(0, lines) [last_line, ..] -> #(string.byte_size(last_line), lines) } }) }) |> result.map(fn(result) { pair.second(result) }) } fn encode_mailbox( mailbox: Mailbox, used_size: Int, mode: encoding.EncodingMode, indent: String, separator: String, ) -> Result(List(String), RenderError) { use display_name <- result.try(encode_field_body( mailbox.name, used_size, structured_encoders, mode, )) use address <- result.try(encode_mailbox_address(mailbox, mode)) let address = address <> separator let address_size = string.byte_size(address) let reversed_display_name = list.reverse(display_name) let last_display_name_size = list.first(reversed_display_name) |> result.unwrap("") |> string.byte_size() let indent_size = string.byte_size(indent) use <- bool.guard( when: address_size + indent_size >= maximum_line_size, return: Error( EncodingFailed(encoding.MaximumSizeExceeded(maximum_line_size)), ), ) case reversed_display_name { [] if used_size + indent_size + address_size <= preferred_line_size -> [ indent <> address, ] [] -> ["", " " <> address] [display_name] if used_size + indent_size + last_display_name_size + 1 + address_size <= preferred_line_size -> [display_name <> " " <> address] [last_display_name, ..other_display_name] if last_display_name_size + 1 + address_size <= preferred_line_size -> [last_display_name <> " " <> address, ..other_display_name] |> list.reverse() display_name -> [" " <> address, ..display_name] |> list.reverse() } |> fn(lines) { case lines { [] -> Ok([]) ["", ..rest] -> Ok(["", ..rest]) [first_line, ..rest] -> Ok([indent <> first_line, ..rest]) } } } fn encode_mailbox_address( mailbox: Mailbox, mode: EncodingMode, ) -> Result(String, RenderError) { mailbox.address |> encode_email_address(mode) |> result.map(fn(address) { case mailbox.name { "" -> address _ -> "<" <> address <> ">" } }) } fn encode_email_address( address: String, mode: encoding.EncodingMode, ) -> Result(String, RenderError) { encoder.encode_email_address(address, mode) |> result.map_error(EncodingFailed) } // // Message ID header encoding // fn message_id_header( name: String, mailbox: Mailbox, mode: EncodingMode, ) -> Result(List(String), RenderError) { use field_name <- result.try(encode_field_name(name)) string.split_once(mailbox.address, "@") |> result.replace_error( InvalidHeaderFieldBody(InvalidEmailAddress(mailbox.address)), ) |> result.try(fn(local_domain) { let #(seconds, _nanoseconds) = timestamp.to_unix_seconds_and_nanoseconds(timestamp.system_time()) let random = bit_array.base64_encode(crypto.strong_random_bytes(10), False) let message_id = int.to_base16(seconds) <> "." <> random <> "@" <> pair.second(local_domain) message_id |> encode_email_address(mode) |> result.map(fn(address) { prepend_field_name(field_name, [address]) }) }) } // // Unstructered header encoding // fn unstructured_header( name: String, value: String, mode: EncodingMode, ) -> Result(List(String), RenderError) { use field_name <- result.try(encode_field_name(name)) use field_body <- result.map(encode_field_body( value, string.byte_size(field_name) + 2, unstructured_encoders, mode, )) prepend_field_name(field_name, field_body) } // // Field name encoding // fn encode_field_name(field_name: String) -> Result(String, RenderError) { let normalized_field_name = normalize_field_name(field_name) case is_valid_field_name(normalized_field_name) { True -> Ok(normalized_field_name) False -> Error(InvalidHeaderFieldName(field_name)) } } fn normalize_field_name(field_name: String) -> String { field_name |> string.replace("_", "-") |> string.split("-") |> list.map(fn(part) { let part = string.uppercase(string.trim(part)) case part { "ID" | "MIME" -> part _ -> string.capitalise(part) } }) |> string.join("-") } fn is_valid_field_name(field_name: String) -> Bool { field_name != "" && { field_name |> string.split("") |> list.all(fn(char) { string.compare(char, "\u{20}") == order.Gt && string.compare(char, "\u{7f}") == order.Lt && string.compare(char, ":") != order.Eq }) } } fn prepend_field_name( field_name: String, field_body: List(String), ) -> List(String) { case field_body { [] -> [field_name <> ":"] [head, ..rest] -> [field_name <> ": " <> head, ..rest] } } // // Body encoding // fn body( value: #(Body, List(Attachment)), mode: encoding.EncodingMode, ) -> Result(List(String), RenderError) { let #(body, attachments) = value use text_body <- result.try(create_body("plain", body.text, mode)) use html_body <- result.try(create_body("html", body.html, mode)) use #(inlined_attachments, attached_attachments) <- result.try( create_attachments(attachments, !list.is_empty(html_body)), ) create_multipart("mixed", [ create_multipart("alternative", [ text_body, create_multipart("related", [html_body, inlined_attachments]), ]), attached_attachments, ]) |> fn(message_part) { case message_part { [] -> Ok([]) [part] -> Ok(["", ..part_to_strings(part)]) _otherwise -> Error(EncodingFailed(encoding.UnknownError)) } } } fn create_body( subtype: String, value: Option(String), mode: encoding.EncodingMode, ) -> Result(List(BodyPart), RenderError) { case value { None -> Ok([]) Some(value) -> { body_encoders |> encoder.determine_encoding_order(value, mode, maximum_line_size) |> list.fold_until( Error(EncodingFailed(encoding.NoEncoderFound)), fn(_acc, enc) { let result = encode_body_part(subtype, value, enc, mode) case result { Ok(body_part) -> Stop(Ok([body_part])) Error(result) -> Continue(Error(EncodingFailed(result))) } }, ) } } } fn encode_body_part( subtype: String, value: String, encoding: Encoding, mode: EncodingMode, ) -> Result(BodyPart, EncoderError) { value |> encoder.encode_string( encoding, mode, 0, preferred_line_size, maximum_line_size, ) |> result.map(fn(lines) { let charset = guess_charset(value) let content_type = string.join(["text/", subtype, "; charset=", charset], "") BodyPart( headers: [ #("Content-Transfer-Encoding", [encoder.name(encoding)]), #("Content-Type", [content_type]), ], body: lines, ) }) } fn create_attachments( attachments: List(Attachment), should_inline: Bool, ) -> Result(#(List(BodyPart), List(BodyPart)), RenderError) { list.try_fold(attachments, #([], []), fn(accumulator, attachment) { attachment |> create_part |> result.map(fn(part) { let #(inlined_attachments, attached_attachments) = accumulator case attachment { InlinedAttachment(..) if should_inline -> #( [part, ..inlined_attachments], attached_attachments, ) _otherwise -> #(inlined_attachments, [part, ..attached_attachments]) } }) }) |> result.map(fn(attachments) { let #(inlined_attachments, attached_attachments) = attachments #(list.reverse(inlined_attachments), list.reverse(attached_attachments)) }) |> result.map_error(fn(error) { EncodingFailed(error) }) } fn create_part(attachment: Attachment) -> Result(BodyPart, EncoderError) { use headers <- result.map( case attachment { InlinedAttachment(content_id:, ..) -> disposition("inline", attachment) |> result.map(pair.new("Content-Disposition", _)) |> result.map(list.wrap) |> result.map( list.append(_, [#("Content-ID", ["<" <> content_id <> ">"])]), ) AttachedAttachment(..) -> disposition("attachment", attachment) |> result.map(pair.new("Content-Disposition", _)) |> result.map(list.wrap) } |> result.map( list.append(_, [ #("Content-Type", [attachment.content_type]), #("Content-Transfer-Encoding", ["base64"]), ]), ), ) let body = attachment.data |> bit_array.base64_encode(True) |> string_chunk([], _, preferred_line_size) BodyPart(headers, body) } fn disposition( disposition: String, attachment: Attachment, ) -> Result(List(String), EncoderError) { use filename <- result.try(encoder.encode_string( attachment.filename, encoding.Text(encoding.Structured), encoding.Ascii, 10, preferred_line_size, maximum_line_size, )) use filename_utf8 <- result.map(encoder.encode_string( attachment.filename_utf8, encoding.PercentEncoding, encoding.Ascii, 7, preferred_line_size - 13, maximum_line_size - 13, )) let filename = case filename { [] | [""] -> [] [line, ..rest] -> [" filename=" <> line, ..rest] } let filename_utf8 = case filename_utf8 { [] | [""] -> [] [line] -> [" filename*=UTF-8''" <> line] lines -> list.index_map(lines, fn(line, index) { case index { 0 -> " filename*1*=UTF-8''" <> line index -> " filename*" <> int.to_string(index + 1) <> "=" <> line } }) } let disposition_header = [disposition, ..filename] |> list.append(filename_utf8) let disposition_header_last_item = list.length(disposition_header) - 1 list.index_map(disposition_header, fn(line, index) { case line { line if index == disposition_header_last_item -> line line -> line <> ";" } }) } fn create_multipart( subtype: String, parts: List(List(BodyPart)), ) -> List(BodyPart) { case list.flatten(parts) { [] -> [] [parts] -> [parts] parts -> { let boundary = "__sendr__" <> bit_array.base16_encode(crypto.strong_random_bytes(10)) let headers = [ #("Content-Type", [ "multipart/" <> subtype <> "; boundary=\"" <> boundary <> "\"", ]), ] let body = list.fold(parts, [], fn(acc, part) { acc |> list.append(["--" <> boundary]) |> list.append(part_to_strings(part)) }) |> list.append(["--" <> boundary <> "--"]) [BodyPart(headers, body)] } } } fn part_to_strings(part: BodyPart) -> List(String) { let BodyPart(headers:, body:) = part headers |> list.flat_map(fn(header) { let #(field_name, field_body) = header prepend_field_name(field_name, field_body) }) |> list.append([""]) |> list.append(body) } // // Utility functions // fn append_strings( data: Result(List(String), RenderError), value: a, callback: fn(a) -> Result(List(String), RenderError), ) -> Result(List(String), RenderError) { data |> result.try(fn(data) { value |> callback() |> result.map(fn(lines) { lines |> list.reverse() |> list.append(data) }) }) } fn maybe_append_strings( data: Result(List(String), RenderError), value: Option(a), callback: fn(a) -> Result(List(String), RenderError), ) -> Result(List(String), RenderError) { case value { None -> data Some(value) -> append_strings(data, value, callback) } } fn string_chunk( accumulator: List(String), string: String, chunk_size: Int, ) -> List(String) { case string { "" -> list.reverse(accumulator) string -> string_chunk( [string.slice(string, 0, chunk_size), ..accumulator], string.drop_start(string, chunk_size), chunk_size, ) } } fn guess_charset(text: String) -> String { let is_ascii = fn(char) { string.compare(char, "\u{0}") == order.Gt && string.compare(char, "\u{80}") == order.Lt } case list.all(string.split(text, ""), is_ascii) { True -> "us-ascii" False -> "utf-8" } } fn encode_field_body( value: String, used: Int, encoders: List(Encoding), mode: encoding.EncodingMode, ) -> Result(List(String), RenderError) { encoders |> encoder.determine_encoding_order(value, mode, maximum_line_size) |> list.fold_until(Error(NoUsableEncodingFound(encoders)), fn(_acc, encoding) { case try_encode_field_body(encoding, value, mode, used) { Ok(_) as result -> Stop(result) Error(_) as error -> Continue(error) } }) } fn try_encode_field_body( encoding: Encoding, value: String, mode: EncodingMode, used: Int, ) -> Result(List(String), RenderError) { use #(first_prefix, prefix, postfix) <- result.try(case encoding { encoding.Base64 -> Ok(#("=?utf-8?B?", " =?utf-8?B?", "?=")) encoding.QuotedPrintable(_) -> Ok(#("=?utf-8?Q?", " =?utf-8?Q?", "?=")) encoding.Text(_) -> Ok(#("", "", "")) encoding -> Error(InvalidEncoding(encoding)) }) let encoding_size = string.byte_size(prefix) - string.byte_size(postfix) encoder.encode_string( value, encoding, mode, used + string.byte_size(first_prefix), preferred_line_size - encoding_size, maximum_line_size - encoding_size, ) |> result.map_error(EncodingFailed) |> result.map(fn(encoded_string) { encoded_string |> list.index_map(fn(line, index) { case index { 0 -> first_prefix <> line <> postfix _ -> prefix <> line <> postfix } }) }) } // // Generic functions // fn try_index_fold( over list: List(a), from initial: acc, with fun: fn(acc, a, Int) -> Result(acc, e), ) -> Result(acc, e) { list.try_fold(list, #(0, initial), fn(accumulator, item) { let #(index, accumulator) = accumulator fun(accumulator, item, index) |> result.map(fn(accumulator) { #(index + 1, accumulator) }) }) |> result.map(pair.second) }