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