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