local cost = require("assay.vendor_cost")
local M = {}
local BASE = "https://app.clayinbox.ai/api/v1"
local PAGE = 100
local BROWSER_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0"
local MAX_PAGES = 50
local SECRET_KEYS = { password = true, app_password = true }
local PERIOD = { MONTHLY = "month", YEARLY = "year", ANNUAL = "year", ANNUALLY = "year" }
local BILLABLE = { active = true }
local NOT_BILLED = {
cancelled = true,
canceled = true,
suspended = true,
deleted = true,
inactive = true,
expired = true,
terminated = true,
}
local ERR = { __tostring = function(e) return "clayinbox: " .. e.message end }
local function fail(code, status, message)
return nil, setmetatable({ code = code, status = status, message = message }, ERR)
end
local function trim(s) return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) end
local function lower(s) return trim(s):lower() end
local function redact(raw)
local out = {}
for k, v in pairs(raw) do out[k] = SECRET_KEYS[k] and "[redacted]" or v end
return out
end
local function refused(where, resp)
if resp.status == 401 or resp.status == 403 then
return fail("auth", resp.status, where .. " rejected the key (HTTP " .. resp.status .. ")")
end
if resp.status == 429 then
return fail("rate_limit", 429, where .. " rate limited (HTTP 429)")
end
if resp.status >= 500 then
return fail("server", resp.status, where .. " HTTP " .. resp.status)
end
return fail("http", resp.status, where .. " HTTP " .. resp.status)
end
function M.map_domain(raw)
local domain = lower(raw.domain)
if domain == "" then return nil end
return {
domain = domain,
provider = "clayinbox",
provider_ref = raw.domain_id,
status = raw.status ~= nil and lower(raw.status) or "unknown",
dns = {
spf = raw.spf == true,
dkim = raw.dkim == true,
dmarc = raw.dmarc == true,
mx = raw.mx_records == true,
},
raw = redact(raw),
}
end
function M.map_box(raw)
local address = lower(raw.username)
local nested = type(raw.domains) == "table" and lower(raw.domains.domain) or ""
local domain = nested ~= "" and nested or address:match("@([^@]+)$")
if address == "" or not domain or domain == "" then return nil end
if address:sub(-(#domain + 1)) ~= ("@" .. domain) then return nil end
return {
address = address,
domain = domain,
status = raw.status ~= nil and lower(raw.status) or "unknown",
provider = "clayinbox",
provider_ref = raw.id,
raw = redact(raw),
}
end
function M.client(opts)
opts = opts or {}
local api_key = opts.api_key or env.get("CLAYINBOX_API_KEY")
if not api_key or trim(api_key) == "" then
error("clayinbox: api key required (opts.api_key or CLAYINBOX_API_KEY)")
end
local base_url = (opts.base_url or BASE):gsub("/+$", "")
local function get(path)
local ok, resp = pcall(http.get, base_url .. path, {
headers = {
["x-api-key"] = api_key,
Accept = "application/json",
["User-Agent"] = BROWSER_UA,
},
})
if not ok then return fail("transport", nil, "GET " .. path .. ": " .. tostring(resp)) end
if resp.status ~= 200 then return refused("GET " .. path, resp) end
local parsed_ok, parsed = pcall(json.parse, resp.body or "")
if not parsed_ok or type(parsed) ~= "table" or type(parsed.data) ~= "table" then
return fail("unreadable", resp.status, "GET " .. path .. " answered without a data object")
end
return parsed.data
end
local function all(path, key, map)
local out = {}
local seen = 0
local truncated = true
for page = 1, MAX_PAGES do
local data, err = get(path .. "?limit=" .. PAGE .. "&page=" .. page)
if not data then return nil, err end
local rows = type(data[key]) == "table" and data[key] or {}
local on_page = 0
for _, raw in ipairs(rows) do
on_page = on_page + 1
local row = map(raw)
if row then out[#out + 1] = row end
end
seen = seen + on_page
local total = data.total_count
if on_page == 0 or on_page < PAGE then truncated = false break end
if type(total) == "number" and page * PAGE >= total then truncated = false break end
end
return out, { truncated = truncated, cap = MAX_PAGES * PAGE, seen = seen }
end
local function send(method, path, body)
local ok, resp = pcall(http[method:lower()], base_url .. path, body and json.encode(body) or "", {
headers = {
["x-api-key"] = api_key,
Accept = "application/json",
["Content-Type"] = "application/json",
["User-Agent"] = BROWSER_UA,
},
})
if not ok then
return fail("transport", nil, method .. " " .. path .. ": " .. tostring(resp))
end
if resp.status < 200 or resp.status >= 300 then return refused(method .. " " .. path, resp) end
local parsed_ok, parsed = pcall(json.parse, resp.body or "")
if not parsed_ok or type(parsed) ~= "table" then
return fail("unreadable", resp.status, method .. " " .. path .. " answered no object")
end
return parsed
end
local c = {}
function c:domains() return all("/domain", "domains", M.map_domain) end
function c:mailboxes() return all("/mailbox", "mailboxes", M.map_box) end
function c:available(domain)
local name = lower(domain)
if name == "" then return fail("config", nil, "available needs a domain") end
local data, err = get("/domain/available?domain=" .. name)
if not data then return nil, err end
return {
domain = name,
available = data.available == true,
price_cents = cost.to_cents(data.price),
raw = data,
}
end
function c:wallet()
local data, err = get("/wallet")
if not data then return nil, err end
return { available_cents = cost.to_cents(data.available), raw = data }
end
function c:order(domain, boxes)
local name = lower(domain)
if name == "" then return fail("config", nil, "order needs a domain") end
if type(boxes) ~= "table" or #boxes == 0 then
return fail("config", nil, "order needs at least one mailbox")
end
local wanted = {}
for i, box in ipairs(boxes) do
local address = lower(box.username or box.address)
if not address:find("@", 1, true) then
return fail("config", nil, "order takes full addresses; box " .. i .. " is " .. address)
end
if address:match("@([^@]+)$") ~= name then
return fail("config", nil, address .. " is not on " .. name)
end
if trim(box.password) == "" then
return fail("config", nil, "no password for " .. address)
end
wanted[i] = {
username = address,
first_name = trim(box.first_name),
last_name = trim(box.last_name),
password = box.password,
}
end
local answer, err = send("POST", "/order", {
import = true,
data = { { domain_name = name, mailboxes = wanted } },
})
if not answer then return nil, err end
local data = type(answer.data) == "table" and answer.data or answer
return { order_id = data.order_id or data.id, raw = data }
end
function c:app_password(id)
local box = trim(id)
if box == "" then return fail("config", nil, "app_password needs a mailbox id") end
local data, err = get("/mailbox/" .. box .. "/app-password")
if not data then return nil, err end
local password = type(data.app_password) == "string" and data.app_password
or (type(data) == "string" and data or nil)
if type(password) ~= "string" or trim(password) == "" then
return fail("not_ready", nil, "no app password for " .. box .. " yet")
end
return password
end
function c:costs()
local rows, meta = all("/mailbox", "mailboxes", function(raw) return raw end)
if not rows then return nil, meta end
local groups, order = {}, {}
local unpriced, inactive, status_unknown, next_billing = 0, 0, 0, nil
for _, raw in ipairs(rows) do
local status = raw.status ~= nil and lower(raw.status) or ""
local cents = cost.to_cents(raw.cost)
local period = PERIOD[trim(raw.billing_cycle):upper()]
if not BILLABLE[status] then
if NOT_BILLED[status] then
inactive = inactive + 1
else
status_unknown = status_unknown + 1
end
elseif cents and period then
local key = cents .. "/" .. period
if not groups[key] then
groups[key] = cost.item({
kind = "box",
unit = "mailbox",
quantity = 0,
unit_price_cents = cents,
period = period,
})
order[#order + 1] = key
end
groups[key].quantity = groups[key].quantity + 1
local due = trim(raw.next_billing_date)
if due ~= "" and (next_billing == nil or due < next_billing) then next_billing = due end
else
unpriced = unpriced + 1
end
end
local items = {}
for _, key in ipairs(order) do items[#items + 1] = groups[key] end
local wallet, wallet_err = get("/wallet")
return {
items = items,
meta = {
priced = true,
currency_known = false,
unpriced = unpriced,
inactive = inactive,
status_unknown = status_unknown,
next_billing_date = next_billing,
truncated = meta.truncated,
cap = meta.cap,
seen = meta.seen,
wallet_available_cents = wallet and cost.to_cents(wallet.available) or nil,
wallet_error = wallet_err,
},
}
end
return c
end
return M