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