Skip to main content

src/sendr_lettermint.gleam

//// 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
  }
}