Skip to main content

src/sendr_sweego.gleam

//// sendr_sweego — Sweego.io email backend for sendr.
////
//// Builds HTTP `Request` values for the Sweego 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, From, InvalidAttachment,
  InvalidBody, InvalidMailbox, NoBody, NoRecipients, ReplyTo,
  RequiredContentIdMissing, RequiredFieldMissing, RequiredFilenameMissing,
  Subject, To, TooManyEntries,
}
import sendr/message.{type Message}
import sendr/message/attachment.{
  type Attachment, AttachedAttachment, InlinedAttachment,
}
import sendr/message/mailbox.{type Mailbox}

const base_uri: String = "https://api.sweego.io/send"

/// Errors that can occur when interacting with the Sweego API.
pub type SweegoError {
  /// The base URI could not be parsed (internal error).
  InvalidUri(String)
  /// The API response body could not be decoded.
  InvalidResponse(statis: Int, error: json.DecodeError)
  /// The API returned a non-200 status with error details.
  ApiError(status: Int, details: List(ApiErrorDetail))
}

/// A single error detail from the Sweego API error response.
pub type ApiErrorDetail {
  ApiErrorDetail(msg: String, error_type: String)
}

/// Configuration for the Sweego API backend.
pub opaque type SweegoConfig {
  SweegoConfig(api_key: String, dry_run: Bool)
}

/// Create a new `SweegoConfig` with the given API key.
pub fn config(api_key api_key: String) -> SweegoConfig {
  SweegoConfig(api_key: api_key, dry_run: False)
}

/// Enable dry-run mode.
///
/// When enabled, the email will not be delivered (test mode).
pub fn dry_run(config: SweegoConfig) -> SweegoConfig {
  SweegoConfig(..config, dry_run: True)
}

/// Build an HTTP `Request` for the Sweego send API from a sendr `Message`.
///
/// Validates the message (from, reply-to, recipients, subject, attachments)
/// and returns `Error(SendrError(SweegoError))` on validation failure.
pub fn request(
  message message: Message,
  config config: SweegoConfig,
) -> Result(Request(String), SendrError(SweegoError)) {
  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.dry_run)

  base_uri
  |> uri.parse()
  |> result.try(request.from_uri)
  |> result.replace_error(InvalidUri(base_uri))
  |> result.map(fn(request) {
    request
    |> request.set_method(Post)
    |> request.set_header("content-type", "application/json")
    |> request.set_header("api-key", config.api_key)
    |> request.set_body(json.to_string(body))
  })
  |> result.map_error(BackendError)
}

/// Parse a `Response` from the Sweego send API.
///
/// On HTTP 200 the response body is decoded and the single `swg_uid` 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(SweegoError)) {
  case response.status {
    200 as status -> {
      let success_decoder = {
        use id <- decode.field("transaction_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 details <- decode.field(
          "detail",
          decode.list({
            use msg <- decode.field("msg", decode.string)
            use typ <- decode.field("type", decode.string)
            decode.success(ApiErrorDetail(msg:, error_type: typ))
          }),
        )
        decode.success(details)
      }

      case json.parse(response.body, using: error_decoder) {
        Ok(details) -> Error(BackendError(ApiError(status, details)))
        Error(error) -> Error(BackendError(InvalidResponse(status, error)))
      }
    }
    status -> {
      let error_decoder = {
        use msg <- decode.field("detail", decode.string)
        decode.success([ApiErrorDetail("Failed invocation", msg)])
      }

      case json.parse(response.body, using: error_decoder) {
        Ok(details) -> Error(BackendError(ApiError(status, details)))
        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([mailbox]) -> validate_mailbox(mailbox, ReplyTo)
    Some(_) -> Error(TooManyEntries(ReplyTo))
  }
}

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

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)) {
  case message.subject {
    None | Some("") -> Error(RequiredFieldMissing(Subject))
    _ -> 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)) {
  case message.body.text, message.body.html {
    None, None -> Error(InvalidBody(NoBody))
    _, _ -> Ok(Nil)
  }
}

fn build_body(message: Message, dry_run: Bool) -> Json {
  [#("provider", json.string("sweego"))]
  |> add_if_some("dry-run", case dry_run {
    True -> Some(json.bool(True))
    False -> None
  })
  |> add_if_some("from", option.map(message.from, mailbox_to_json))
  |> add_if_some(
    "reply-to",
    option.then(message.reply_to, fn(reply_to) {
      reply_to
      |> list.first()
      |> option.from_result()
      |> option.map(mailbox_to_json)
    }),
  )
  |> add_if_some(
    "recipients",
    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("message-txt", option.map(message.body.text, json.string))
  |> add_if_some("message-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 {
  json.object(
    [#("email", json.string(mailbox.address))]
    |> add_if_some(
      "name",
      mailbox.name |> string.to_option() |> option.map(json.string),
    ),
  )
}

fn attachment_to_json(attachment: Attachment) -> Json {
  let filename = case attachment.filename {
    "" -> attachment.filename_utf8
    filename -> filename
  }
  let #(disposition, content_id, is_related) = case attachment {
    AttachedAttachment(..) -> #("attachment", None, None)
    InlinedAttachment(content_id:, ..) -> #(
      "inline",
      Some(content_id),
      Some(True),
    )
  }
  let content = bit_array.base64_encode(attachment.data, True)

  json.object(
    [
      #("filename", json.string(filename)),
      #("disposition", json.string(disposition)),
      #("content", json.string(content)),
    ]
    |> add_if_some("content_id", option.map(content_id, json.string))
    |> add_if_some("is_related", option.map(is_related, json.bool)),
  )
}

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