local cost = require("assay.vendor_cost")
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 SEQUENCE_STATUS = { paused = true, active = true }
local DEFAULT_SMTP = { host = "smtp.gmail.com", port = 587 }
local DEFAULT_IMAP = { host = "imap.gmail.com", port = 993 }
local PAGE = 100
local INTERNAL_PAGE = 50
local MAX_PAGES = 20
local PLAN_LIMITS = {
{ field = "emailsPerMonthLimit", name = "emails_per_month" },
{ field = "activatedLeadsPerMonthLimit", name = "activated_leads_per_month" },
{ field = "validationsPerMonthLimit", name = "validations_per_month" },
{ field = "personalizationsPerMonthLimit", name = "personalizations_per_month" },
{ field = "socialActionsPerMonthLimit", name = "social_actions_per_month" },
{ field = "linkedInProfilesLimit", name = "linkedin_profiles" },
}
local PLAN_PERIOD = {
MONTH = "month",
MONTHLY = "month",
YEAR = "year",
YEARLY = "year",
ANNUAL = "year",
ANNUALLY = "year",
}
local PLAN_CREDITS = {
{ field = "emailCreditsLeft", name = "emails" },
{ field = "leadCreditsLeft", name = "leads" },
{ field = "emailValidationCreditsLeft", name = "validations" },
{ field = "personalizationCreditsLeft", name = "personalizations" },
{ field = "socialActionCreditsLeft", name = "social_actions" },
}
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
local TRANSPORT_KEYS = { smtp = true, imap = true, password = true, appPassword = true }
function M.without_transport(raw)
if type(raw) ~= "table" then return raw end
local out = {}
for k, v in pairs(raw) do
if not TRANSPORT_KEYS[k] then out[k] = v end
end
return out
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 internal_headers()
return {
Authorization = "Bearer " .. token,
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:set_rotation(sequence_id, mailbox_ids)
if not sequence_id or trim(sequence_id) == "" then
return fail("config", nil, "set_rotation needs a sequence id")
end
if type(mailbox_ids) ~= "table" then
return fail("config", nil, "set_rotation needs a list of mailbox ids")
end
local ids = setmetatable({}, { __jsontype = "array" })
for i, id in ipairs(mailbox_ids) do ids[i] = id end
return request("PUT",
"/workspaces/" .. workspace .. "/sequences/" .. trim(sequence_id) .. "/mailboxes",
{ mailboxIds = ids })
end
function c:set_sequence_status(sequence_id, status)
if not sequence_id or trim(sequence_id) == "" then
return fail("config", nil, "set_sequence_status needs a sequence id")
end
local wanted = lower(status)
if not SEQUENCE_STATUS[wanted] then
return fail("config", nil,
"sequence status must be \"paused\" or \"active\", not " .. tostring(status))
end
return request("PUT",
"/workspaces/" .. workspace .. "/sequences/" .. trim(sequence_id) .. "/status",
{ status = wanted })
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 = internal_headers()
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
function c:mailbox_internal(id)
if not id or trim(id) == "" then return fail("config", nil, "mailbox id required") end
local signed, err = self:sign_in()
if not signed then return nil, err end
local body, call_err = request("GET",
internal_base .. "/workspaces/" .. workspace .. "/mailboxes/" .. trim(id),
nil, internal_headers())
if not body then return nil, call_err end
local raw = type(body) == "table" and (type(body.data) == "table" and body.data or body) or nil
local row = raw and M.map_internal_box(raw) or nil
if not row then
return fail("unreadable", nil, "GET mailbox " .. trim(id) .. " answered no readable mailbox")
end
return row
end
function c:mailbox_id(id_or_address)
local given = trim(id_or_address)
if given == "" then return fail("config", nil, "mailbox id or address required") end
if not given:find("@", 1, true) then return given end
local wanted = lower(given)
local rows, err = self:mailboxes_internal()
if not rows then return nil, err end
for _, row in ipairs(rows) do
if row.address == wanted then return row.provider_ref end
end
return fail("not_found", nil, "no mailbox " .. wanted .. " in this workspace")
end
function c:connect_smtp(address, password, opts)
opts = opts or {}
local addr = lower(address)
if addr == "" or not addr:find("@", 1, true) then
return fail("config", nil, "connect_smtp needs an email address")
end
if not password or trim(password) == "" then
return fail("config", nil, "connect_smtp needs the mailbox password")
end
local smtp = opts.smtp or DEFAULT_SMTP
local imap = opts.imap or DEFAULT_IMAP
local first = trim(opts.first)
local body = {
firstName = first ~= "" and first or addr:match("^([^@]+)"),
lastName = trim(opts.last),
address = addr,
smtp = {
host = smtp.host, port = smtp.port,
username = smtp.username or addr, password = password,
},
imap = {
host = imap.host, port = imap.port,
username = imap.username or addr, password = password,
},
}
if type(opts.daily_limit) == "number" then body.dailyEmailLimit = opts.daily_limit end
local created, err = request("POST", "/workspaces/" .. workspace .. "/mailboxes", body)
if not created then return nil, err end
local row = type(created) == "table" and M.map_box(created) or nil
if not row then
local said = type(created) == "table" and type(created.message) == "string"
and created.message or "no address in the answer"
return fail("refused", nil, "connect refused: " .. said)
end
row.connected = row.status == "active"
row.raw = M.without_transport(created)
return row
end
function c:set_warmup(id_or_address, on)
if type(on) ~= "boolean" then
return fail("config", nil, "set_warmup needs true or false")
end
local id, err = self:mailbox_id(id_or_address)
if not id then return nil, err end
local signed, sign_err = self:sign_in()
if not signed then return nil, sign_err end
local put, put_err = request("PUT",
internal_base .. "/workspaces/" .. workspace .. "/mailboxes/" .. id,
{ warmupActivated = on }, internal_headers())
if not put then return nil, put_err end
return self:mailbox_internal(id)
end
local function plan_period(account, plan)
local sources = { plan.interval, plan.billingPeriod, account.billingCycle }
for i = 1, 3 do
local mapped = PLAN_PERIOD[trim(sources[i]):upper()]
if mapped then return mapped end
end
return nil
end
function c:costs()
local signed, err = self:sign_in()
if not signed then return nil, err end
local body, call_err = request("GET", internal_base .. "/me", nil, internal_headers())
if call_err then return nil, call_err end
local user = type(body) == "table" and type(body.user) == "table" and body.user or nil
local account = user and type(user.account) == "table" and user.account or nil
if not account then
return fail("unreadable", nil, "GET /me answered without an account object")
end
local plan = type(account.activePlan) == "table" and account.activePlan or {}
local limits, credits = {}, {}
for _, entry in ipairs(PLAN_LIMITS) do
if type(plan[entry.field]) == "number" then limits[entry.name] = plan[entry.field] end
end
for _, entry in ipairs(PLAN_CREDITS) do
if type(account[entry.field]) == "number" then credits[entry.name] = account[entry.field] end
end
return {
items = { cost.item({
kind = "plan",
unit = "plan",
ref = plan.name or account.activePlanId,
quantity = 1,
period = plan_period(account, plan),
}) },
meta = {
priced = false,
currency_known = false,
plan = {
id = account.activePlanId,
name = plan.name,
status = account.subscriptionStatus,
started_at = account.planStartedAt,
trial_expires_at = account.freeTrialExpiresAt,
},
limits = limits,
credits_left = credits,
},
}
end
return c
end
return M