Skip to main content

src/sendr_scaleway.gleam

//// sendr_scaleway — Scaleway Transactional Email backend for sendr.
////
//// Builds HTTP `Request` values for the Scaleway Transactional Email 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, AttachmentTypeNotSupported, BackendError, Bcc, Cc,
  From, InvalidAttachment, InvalidBody, InvalidMailbox, NoBody, NoRecipients,
  ReplyTo, 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.scaleway.com/transactional-email/v1alpha1/regions/"

/// Errors that can occur when interacting with the Scaleway API.
pub type ScalewayError {
  /// The 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-200 status with error details.
  ApiError(status: Int, details: List(ApiErrorDetail))
}

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

/// Configuration for the Scaleway Transactional Email API backend.
pub opaque type ScalewayConfig {
  ScalewayConfig(api_key: String, region: String, project_id: String)
}

/// Create a new `ScalewayConfig`.
///
/// `api_key` is your Scaleway secret key (used for the `X-Auth-Token` header).
/// `region` is the Scaleway region (e.g. `"fr-par"`).
/// `project_id` is your Scaleway Project UUID.
pub fn config(
  api_key api_key: String,
  region region: String,
  project_id project_id: String,
) -> ScalewayConfig {
  ScalewayConfig(api_key: api_key, region: region, project_id: project_id)
}

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

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

/// Parse a `Response` from the Scaleway send API.
///
/// On HTTP 200 the response body is decoded and the `message_id` of the first
/// email is returned. All other statuses are treated as API errors.
pub fn response(
  response response: Response(String),
) -> Result(String, SendrError(ScalewayError)) {
  case response.status {
    200 -> {
      let email_decoder = {
        use msg_id <- decode.field("message_id", decode.string)
        decode.success(msg_id)
      }

      let success_decoder = {
        use emails <- decode.field("emails", decode.list(email_decoder))
        decode.success(emails)
      }

      case json.parse(response.body, using: success_decoder) {
        Ok([msg_id, ..]) -> Ok(msg_id)
        Ok([]) ->
          Error(
            BackendError(InvalidResponse(
              200,
              json.UnableToDecode([
                decode.DecodeError(
                  "At least one email with message_id",
                  "List",
                  [],
                ),
              ]),
            )),
          )
        Error(error) -> Error(BackendError(InvalidResponse(200, error)))
      }
    }
    status -> {
      let error_decoder = {
        use msg <- decode.field("message", 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 uri(region: String) -> String {
  base_uri <> region <> "/emails"
}

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(_) -> 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(..) ->
            Error(InvalidAttachment(AttachmentTypeNotSupported, attachment))
          AttachedAttachment(..) -> 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, config: ScalewayConfig) -> Json {
  []
  |> add_if_some("from", option.map(message.from, 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("project_id", json.string(config.project_id))
  |> 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 content = bit_array.base64_encode(attachment.data, True)
  let content_type = attachment.content_type

  json.object([
    #("name", json.string(filename)),
    #("type", json.string(content_type)),
    #("content", json.string(content)),
  ])
}

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