local M = {}
local lp = require("assay.lead_provider")
local url = require("assay.url")
local DEFAULT_COST_CENTS = 0
local function trim(s) return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", "")) end
local function first_of(list)
if type(list) == "table" then return list[1] end
if type(list) == "string" and list ~= "" then return list end
return nil
end
local function as_list(value)
if type(value) == "table" then return value end
if type(value) == "string" and value ~= "" then return { value } end
return {}
end
local function company_param(value)
local list = as_list(value)
if #list == 0 then return nil end
return list
end
local function split_name(profile)
local full = trim(profile.full_name)
local first, last = profile.first_name, profile.last_name
if (not first or first == "") and full ~= "" then
first, last = full:match("^(%S+)%s+(.+)$")
end
return first, last, (full ~= "" and full or nil)
end
local function company_of(profile)
local company = profile.company
if type(company) == "table" then
return company.name, profile.company_domain or company.domain or company.email_domain
end
return company, profile.company_domain
end
local function to_person(profile, from)
profile = profile or {}
local first, last, full = split_name(profile)
local company_name, company_domain = company_of(profile)
local emails = {}
for _, address in ipairs(as_list(profile.work_email)) do
emails[#emails + 1] = lp.email("contactout", from,
{ address = address, email_type = "provider" })
end
for _, address in ipairs(as_list(profile.email)) do
local seen = false
for _, existing in ipairs(emails) do
if existing.address == address then seen = true end
end
if not seen then
emails[#emails + 1] = lp.email("contactout", from,
{ address = address, email_type = "provider" })
end
end
return lp.person("contactout", from, {
first_name = first,
last_name = last,
full_name = full,
title = profile.headline or profile.job_title,
company = company_name,
domain = company_domain,
linkedin = profile.url or profile.linkedin,
location = profile.location,
emails = emails,
})
end
function M.client(gate, opts)
if type(gate) ~= "table" or type(gate.paid) ~= "function" then
error("contactout: a lead_provider gate is required — every call here is paid")
end
opts = opts or {}
local token = opts.token or env.get("CONTACTOUT_TOKEN")
if not token or trim(token) == "" then
error("contactout: token required (opts.token or CONTACTOUT_TOKEN)")
end
local base_url = (opts.base_url or "https://api.contactout.com"):gsub("/+$", "")
local function headers()
return { token = token, Accept = "application/json", ["Content-Type"] = "application/json" }
end
local function fail(where, resp)
if resp.status == 401 or resp.status == 403 then
error("contactout: " .. where .. " rejected the token (HTTP " .. resp.status .. ")")
end
if resp.status == 429 then
error("contactout: " .. where .. " rate limited (HTTP 429)")
end
error("contactout: " .. where .. " HTTP " .. resp.status .. ": " .. (resp.body or ""))
end
local function api(method, path_str, payload)
local target = base_url .. path_str
local resp
if method == "POST" then
resp = http.post(target, payload or {}, { headers = headers() })
else
resp = http.get(target, { headers = headers() })
end
if resp.status == 404 then return nil, target end
if resp.status ~= 200 and resp.status ~= 201 then fail(method .. " " .. path_str, resp) end
local ok, parsed = pcall(json.parse, resp.body or "")
if not ok then error("contactout: " .. path_str .. " returned unparseable JSON") end
return parsed, target
end
local c = {}
function c:enrich_linkedin(profile_url, o)
o = o or {}
return gate:paid("find_person", o.cents or DEFAULT_COST_CENTS, function()
local body, from = api("GET", "/v1/linkedin/enrich?profile=" .. url.encode(trim(profile_url)))
if not body or not body.profile then return nil end
return to_person(body.profile, from)
end)
end
function c:profile_by_email(address, o)
o = o or {}
return gate:paid("find_person", o.cents or DEFAULT_COST_CENTS, function()
local body, from = api("GET", "/v1/people/person?email=" .. url.encode(trim(address)))
if not body or not body.profile then return nil end
return to_person(body.profile, from)
end)
end
function c:find_person(spec, o)
spec, o = spec or {}, o or {}
if spec.linkedin_url and not spec.full_name and not spec.last_name then
return self:enrich_linkedin(spec.linkedin_url, o)
end
return gate:paid("find_person", o.cents or DEFAULT_COST_CENTS, function()
local body, from = api("POST", "/v1/people/enrich", {
full_name = spec.full_name,
first_name = spec.first_name,
last_name = spec.last_name,
company = company_param(spec.company),
location = spec.location,
linkedin_url = spec.linkedin_url,
email = spec.email,
include = spec.include or { "work_email" },
})
if not body or not body.profile then return nil end
return to_person(body.profile, from)
end)
end
function c:resolve_email(spec, o)
spec, o = spec or {}, o or {}
local person = gate:paid("resolve_email", o.cents or DEFAULT_COST_CENTS, function()
local body, from
if spec.linkedin_url then
body, from = api("GET", "/v1/linkedin/enrich?profile=" .. url.encode(trim(spec.linkedin_url)))
else
body, from = api("POST", "/v1/people/enrich", {
full_name = spec.full_name,
first_name = spec.first_name,
last_name = spec.last_name,
company = company_param(spec.company),
include = { "work_email" },
})
end
if not body or not body.profile then return nil end
return to_person(body.profile, from)
end)
return person and person.emails or {}
end
return c
end
M.first_of = first_of
return M