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