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 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 c = {}
function c:domains() return all("/domain", "domains", M.map_domain) end
function c:mailboxes() return all("/mailbox", "mailboxes", M.map_box) end
return c
end
return M