local M = {}
local PUBLIC_BASE = "https://api.salesforge.ai/public/v2"
local INTERNAL_BASE = "https://api.salesforge.ai"
local IDENTITY_URL = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword"
local FIREBASE_WEB_API_KEY = "AIzaSyCSvPu4xQeXnowWbgt2uRFGwAuMhkbJo-o"
local BROWSER_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0"
local PAGE = 100
local INTERNAL_PAGE = 50
local MAX_PAGES = 20
local ERR = { __tostring = function(e) return "salesforge: " .. 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
function M.map_box(raw)
local address = lower(raw.address)
if address == "" or not address:find("@", 1, true) then return nil end
return {
address = address,
domain = address:match("@([^@]+)$"),
status = raw.status ~= nil and lower(raw.status) or "unknown",
provider = "salesforge",
provider_ref = raw.id,
daily_limit = raw.dailyEmailLimit,
mailbox_provider = raw.mailboxProvider,
connected = true,
raw = raw,
}
end
function M.map_internal_box(raw)
local address = lower(raw.address)
if address == "" or not address:find("@", 1, true) then return nil end
local warmup
if raw.warmupActivated == true then
local left = raw.daysUntilWarm
if type(left) == "number" and left == left then
warmup = { days_until_warm = math.max(0, math.floor(left + 0.5)) }
else
warmup = {}
end
warmup.heat = M.heat(raw.reputationScore)
warmup.activated = true
end
return {
address = address,
domain = address:match("@([^@]+)$"),
status = raw.status ~= nil and lower(raw.status) or "unknown",
provider = "salesforge",
provider_ref = raw.id,
warmup = warmup,
raw = raw,
}
end
function M.heat(raw)
if type(raw) ~= "number" or raw ~= raw then return nil end
local n = math.floor(raw + 0.5)
if n < 0 or n > 100 then return nil end
return n
end
function M.client(opts)
opts = opts or {}
local api_key = opts.api_key or env.get("SALESFORGE_API_KEY")
if not api_key or trim(api_key) == "" then
error("salesforge: api key required (opts.api_key or SALESFORGE_API_KEY)")
end
local workspace = opts.workspace_id
if not workspace or trim(workspace) == "" then
error("salesforge: workspace_id required")
end
workspace = trim(workspace)
local email = opts.email or env.get("SALESFORGE_EMAIL")
local password = opts.password or env.get("SALESFORGE_PASSWORD")
local base_url = (opts.base_url or PUBLIC_BASE):gsub("/+$", "")
local internal_base = (opts.internal_base_url or INTERNAL_BASE):gsub("/+$", "")
local identity_url = opts.identity_url or IDENTITY_URL
local token
local function refused(where, status)
if status == 401 or status == 403 then
return fail("auth", status, where .. " rejected the credentials (HTTP " .. status .. ")")
end
if status == 429 then
return fail("rate_limit", 429, where .. " rate limited (HTTP 429)")
end
if status == 402 then
return fail("plan", 402, where .. " needs a Growth plan (HTTP 402)")
end
if status >= 500 then
return fail("server", status, where .. " HTTP " .. status)
end
return fail("http", status, where .. " HTTP " .. status)
end
local function send(method, target, headers, body)
local ok, resp
if method == "GET" then
ok, resp = pcall(http.get, target, { headers = headers })
else
ok, resp = pcall(http[method:lower()], target, body and json.encode(body) or "", { headers = headers })
end
if not ok then return nil, tostring(resp) end
return resp
end
local function public_headers()
return {
Authorization = api_key,
Accept = "application/json",
["Content-Type"] = "application/json",
["User-Agent"] = BROWSER_UA,
}
end
local function request(method, path, body, headers)
local where = method .. " " .. path
local target = (path:sub(1, 4) == "http" and path or base_url .. path)
local resp, transport = send(method, target, headers or public_headers(), body)
if not resp then return fail("transport", nil, where .. ": " .. transport) end
if resp.status < 200 or resp.status >= 300 then return refused(where, resp.status) end
local text = trim(resp.body)
if text == "" then return true end
local ok, parsed = pcall(json.parse, text)
if not ok then
return fail("unreadable", resp.status, where .. " answered with a body that is not JSON")
end
return parsed
end
local function all(path, map)
local out = {}
local seen = 0
local truncated = true
for page = 0, MAX_PAGES - 1 do
local sep = path:find("?", 1, true) and "&" or "?"
local body, err = request("GET", path .. sep .. "limit=" .. PAGE .. "&offset=" .. (page * PAGE))
if not body then return nil, err end
if type(body) ~= "table" then truncated = false break end
local rows = type(body.data) == "table" and body.data or {}
local on_page = 0
for _, raw in ipairs(rows) do
on_page = on_page + 1
local row = map and map(raw) or raw
if row then out[#out + 1] = row end
end
seen = seen + on_page
local total = body.total
if on_page == 0 or on_page < PAGE then truncated = false break end
if type(total) == "number" and (page + 1) * PAGE >= total then truncated = false break end
end
return out, { truncated = truncated, cap = MAX_PAGES * PAGE, seen = seen }
end
local c = {}
function c:workspaces() return all("/workspaces") end
function c:mailboxes() return all("/workspaces/" .. workspace .. "/mailboxes", M.map_box) end
function c:sequences() return all("/workspaces/" .. workspace .. "/sequences") end
function c:sequence(id)
if not id or trim(id) == "" then return fail("config", nil, "sequence id required") end
return request("GET", "/workspaces/" .. workspace .. "/sequences/" .. trim(id))
end
function c:create_contact(fields)
if type(fields) ~= "table" or trim(fields.firstName) == "" then
return fail("config", nil, "create_contact needs at least firstName")
end
return request("POST", "/workspaces/" .. workspace .. "/contacts", fields)
end
function c:enrol(sequence_id, contact_ids)
if not sequence_id or trim(sequence_id) == "" then
return fail("config", nil, "enrol needs a sequence id")
end
if type(contact_ids) ~= "table" or #contact_ids == 0 then
return fail("config", nil, "enrol needs at least one contact id")
end
return request("PUT", "/workspaces/" .. workspace .. "/sequences/" .. trim(sequence_id) .. "/contacts",
{ contactIds = contact_ids })
end
function c:dnc(addresses)
if type(addresses) ~= "table" or #addresses == 0 then
return fail("config", nil, "dnc needs at least one address")
end
return request("POST", "/workspaces/" .. workspace .. "/dnc/bulk", { dncs = addresses })
end
function c:reply(mailbox_id, email_id, body)
if not mailbox_id or trim(mailbox_id) == "" or not email_id or trim(email_id) == "" then
return fail("config", nil, "reply needs a mailbox id and an email id")
end
return request("POST",
"/workspaces/" .. workspace .. "/mailboxes/" .. trim(mailbox_id)
.. "/emails/" .. trim(email_id) .. "/reply",
{ content = tostring(body or ""), includeHistory = true })
end
function c:sign_in()
if token then return true end
if not email or trim(email) == "" or not password or trim(password) == "" then
return fail("sign_in", nil, "internal API needs email and password (opts or SALESFORGE_EMAIL/SALESFORGE_PASSWORD)")
end
local target = identity_url .. (identity_url:find("?", 1, true) and "&" or "?")
.. "key=" .. FIREBASE_WEB_API_KEY
local resp, transport = send("POST", target, {
["Content-Type"] = "application/json",
Accept = "application/json",
["User-Agent"] = BROWSER_UA,
}, { email = email, password = password, returnSecureToken = true })
if not resp then return fail("sign_in", nil, "sign-in transport failure: " .. transport) end
local ok, parsed = pcall(json.parse, resp.body or "")
local id_token = ok and type(parsed) == "table" and parsed.idToken
if type(id_token) ~= "string" or id_token == "" then
return fail("sign_in", resp.status, "sign-in failed (HTTP " .. tostring(resp.status) .. ")")
end
token = id_token
return true
end
function c:mailboxes_internal()
local signed, err = self:sign_in()
if not signed then return nil, err end
local headers = {
Authorization = "Bearer " .. token,
Accept = "application/json",
["Content-Type"] = "application/json",
["User-Agent"] = BROWSER_UA,
}
local out = {}
local seen = 0
local truncated = true
for page = 1, MAX_PAGES do
local target = internal_base .. "/workspaces/" .. workspace
.. "/mailboxes?page=" .. page .. "&size=" .. INTERNAL_PAGE
local body, call_err = request("GET", target, nil, headers)
if not body then return nil, call_err end
if type(body) ~= "table" then truncated = false break end
local rows = type(body.data) == "table" and body.data or {}
local on_page = 0
for _, raw in ipairs(rows) do
on_page = on_page + 1
local row = M.map_internal_box(raw)
if row then out[#out + 1] = row end
end
seen = seen + on_page
local pagination = type(body.pagination) == "table" and body.pagination or {}
if on_page == 0 then truncated = false break end
if type(pagination.totalPages) == "number" and page >= pagination.totalPages then
truncated = false
break
end
if trim(pagination.next) == "" then truncated = false break end
end
return out, { truncated = truncated, cap = MAX_PAGES * INTERNAL_PAGE, seen = seen }
end
return c
end
return M