//// sendr_lettermint — Lettermint email backend for sendr.
////
//// Builds HTTP `Request` values for the Lettermint send API and parses
//// `Response` values returned by it.
import gleam/bit_array
import gleam/dynamic/decode
import gleam/http.{Post}
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/json.{type Json}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import gleam/uri
import sendr.{
type Field, type SendrError, BackendError, Bcc, Cc, FieldExceedsMaximumLength,
From, HtmlToShort, InvalidAttachment, InvalidBody, InvalidMailbox, NoBody,
NoRecipients, ReplyTo, RequiredContentIdMissing, RequiredFieldMissing,
RequiredFilenameMissing, Subject, TextToShort, To,
}
import sendr/message.{type Message}
import sendr/message/attachment.{type Attachment, InlinedAttachment}
import sendr/message/mailbox.{type Mailbox, Mailbox}
const base_uri: String = "https://api.lettermint.co/v1/send"
/// Errors that can occur when interacting with the Lettermint API.
pub type LettermintError {
/// The base URI could not be parsed (internal error).
InvalidUri(String)
/// The API response body could not be decoded.
InvalidResponse(status: Int, error: json.DecodeError)
/// The API returned a non-202 status with error details.
ApiError(status: Int, code: Option(String), message: String)
}
/// Configuration for the Lettermint API backend.
pub opaque type LettermintConfig {
LettermintConfig(token: String, route: String)
}
/// Create a new `LettermintConfig` with the given API token.
pub fn config(token token: String) -> LettermintConfig {
LettermintConfig(token: token, route: "")
}
/// Set the route for this config.
///
/// Routes control deliverability settings in your Lettermint account.
pub fn set_route(
config config: LettermintConfig,
route route: String,
) -> LettermintConfig {
LettermintConfig(..config, route: route)
}
/// Build an HTTP `Request` for the Lettermint send API from a sendr `Message`.
///
/// Validates the message (from, reply-to, recipients, subject, attachments)
/// and returns `Error(SendrError(LettermintError))` on validation failure.
pub fn request(
message message: Message,
config config: LettermintConfig,
) -> Result(Request(String), SendrError(LettermintError)) {
use _ <- result.try(validate_from(message))
use _ <- result.try(validate_reply_to(message))
use _ <- result.try(validate_recipients(message))
use _ <- result.try(validate_subject(message))
use _ <- result.try(validate_attachments(message))
use _ <- result.try(validate_body(message))
let body = build_body(message, config)
base_uri
|> uri.parse()
|> result.try(request.from_uri)
|> result.replace_error(InvalidUri(base_uri))
|> result.map(fn(req) {
req
|> request.set_method(Post)
|> request.set_header("content-type", "application/json")
|> request.set_header("x-lettermint-token", config.token)
|> request.set_body(json.to_string(body))
})
|> result.map_error(BackendError)
}
/// Parse a `Response` from the Lettermint send API.
///
/// On HTTP 202 the response body is decoded and the `message_id` value
/// is returned. On HTTP 422 the `detail` array is decoded into
/// `ApiErrorDetail` entries. All other statuses are treated as generic
/// API errors.
pub fn response(
response response: Response(String),
) -> Result(String, SendrError(LettermintError)) {
case response.status {
202 as status -> {
let success_decoder = {
use id <- decode.field("message_id", decode.string)
decode.success(id)
}
json.parse(response.body, using: success_decoder)
|> result.map_error(fn(error) {
BackendError(InvalidResponse(status, error))
})
}
422 as status -> {
let error_decoder = {
use error <- decode.field("error", {
use code <- decode.field("code", decode.string)
use message <- decode.field("message", decode.string)
decode.success(ApiError(status, Some(code), message))
})
decode.success(error)
}
case json.parse(response.body, using: error_decoder) {
Ok(api_error) -> Error(BackendError(api_error))
Error(error) -> Error(BackendError(InvalidResponse(status, error)))
}
}
status -> {
let error_decoder = {
use message <- decode.field("message", decode.string)
decode.success(ApiError(status, None, message))
}
case json.parse(response.body, using: error_decoder) {
Ok(api_error) -> Error(BackendError(api_error))
Error(error) -> Error(BackendError(InvalidResponse(status, error)))
}
}
}
}
fn validate_from(message: Message) -> Result(Nil, SendrError(error)) {
case message.from {
None -> Error(RequiredFieldMissing(From))
Some(mailbox) -> validate_mailbox(mailbox, From)
}
}
fn validate_reply_to(message: Message) -> Result(Nil, SendrError(error)) {
case message.reply_to {
None | Some([]) -> Ok(Nil)
Some(mailboxes) ->
list.try_each(mailboxes, validate_mailbox(_, ReplyTo))
|> result.replace(Nil)
}
}
fn validate_recipients(message: Message) -> Result(Nil, SendrError(error)) {
let recipients =
[message.to, message.cc, message.bcc]
|> option.values()
|> list.flatten()
let validate = fn(mailboxes, field) {
mailboxes
|> option.unwrap([])
|> list.try_each(validate_mailbox(_, field))
}
case recipients {
[] -> Error(NoRecipients)
_ ->
validate(message.bcc, Bcc)
|> result.or(validate(message.cc, Cc))
|> result.or(
validate(message.to, To)
|> result.try(fn(_) {
case option.unwrap(message.to, []) {
[] -> Error(RequiredFieldMissing(To))
_ -> Ok(Nil)
}
}),
)
}
}
fn validate_mailbox(
mailbox: Mailbox,
field: Field,
) -> Result(Nil, SendrError(error)) {
case string.split_once(mailbox.address, "@") {
Ok(#(local, domain)) if local != "" && domain != "" -> Ok(Nil)
_ -> Error(InvalidMailbox(field, mailbox))
}
}
fn validate_subject(message: Message) -> Result(Nil, SendrError(error)) {
let subject_length = message.subject |> option.map(string.length)
case subject_length {
None | Some(0) -> Error(RequiredFieldMissing(Subject))
Some(length) if length > 998 ->
Error(FieldExceedsMaximumLength(Subject, 998, length))
_ -> Ok(Nil)
}
}
fn validate_attachments(message: Message) -> Result(Nil, SendrError(error)) {
list.try_each(message.attachments, fn(attachment) {
case attachment.filename, attachment.filename_utf8 {
"", "" -> Error(InvalidAttachment(RequiredFilenameMissing, attachment))
_, _ ->
case attachment {
InlinedAttachment(content_id: "", ..) ->
Error(InvalidAttachment(RequiredContentIdMissing, attachment))
_ -> Ok(Nil)
}
}
})
}
fn validate_body(message: Message) -> Result(Nil, SendrError(error)) {
let text_length = message.body.text |> option.map(string.length)
let html_length = message.body.html |> option.map(string.length)
case text_length, html_length {
None, None -> Error(InvalidBody(NoBody))
Some(0), Some(0) -> Error(InvalidBody(NoBody))
Some(length), _ if length < 3 -> Error(InvalidBody(TextToShort(3, length:)))
_, Some(length) if length < 3 -> Error(InvalidBody(HtmlToShort(3, length:)))
_, _ -> Ok(Nil)
}
}
fn build_body(message: Message, config: LettermintConfig) -> Json {
[]
|> add_if_some("route", case config.route {
"" -> None
route -> Some(json.string(route))
})
|> add_if_some("from", option.map(message.from, mailbox_to_json))
|> add_if_some(
"reply_to",
option.map(message.reply_to, fn(mailboxes) {
json.array(mailboxes, of: mailbox_to_json)
}),
)
|> add_if_some(
"to",
option.map(message.to, json.array(_, of: mailbox_to_json)),
)
|> add_if_some(
"cc",
option.map(message.cc, json.array(_, of: mailbox_to_json)),
)
|> add_if_some(
"bcc",
option.map(message.bcc, json.array(_, of: mailbox_to_json)),
)
|> add_if_some("subject", option.map(message.subject, json.string))
|> add_if_some("text", option.map(message.body.text, json.string))
|> add_if_some("html", option.map(message.body.html, json.string))
|> add("attachments", json.array(message.attachments, of: attachment_to_json))
|> json.object()
}
fn mailbox_to_json(mailbox: Mailbox) -> Json {
case mailbox {
Mailbox(name: "", address:) -> json.string(address)
Mailbox(name:, address:) -> json.string(name <> " <" <> address <> ">")
}
}
fn attachment_to_json(attachment: Attachment) -> Json {
let filename = case attachment.filename {
"" -> attachment.filename_utf8
filename -> filename
}
let content = bit_array.base64_encode(attachment.data, True)
let content_type = case attachment.content_type {
"" -> "application/octet-stream"
ct -> ct
}
json.object(
[
#("filename", json.string(filename)),
#("content", json.string(content)),
#("content_type", json.string(content_type)),
]
|> add_if_some("content_id", case attachment {
InlinedAttachment(content_id: cid, ..) if cid != "" ->
Some(json.string(cid))
_ -> None
}),
)
}
fn add(
entries: List(#(String, Json)),
key: String,
value: Json,
) -> List(#(String, Json)) {
list.append(entries, [#(key, value)])
}
fn add_if_some(
entries: List(#(String, Json)),
key: String,
value: Option(Json),
) -> List(#(String, Json)) {
case value {
Some(value) -> add(entries, key, value)
None -> entries
}
}