From ee58358330b3d63f35e11289e1be318fed26138d Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sun, 12 Jul 2026 14:15:53 +0200 Subject: [PATCH] feat(hunter): new Hunter.io email-OSINT integration Hunter.io API v2, 5 commands: domain search, email finder, email verifier, account. API-key auth, stdlib-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/hunter/manifest.yaml | 65 +++++++++++++++++++ integrations/hunter/scripts/account.py | 39 +++++++++++ integrations/hunter/scripts/domain_search.py | 44 +++++++++++++ integrations/hunter/scripts/email_finder.py | 48 ++++++++++++++ integrations/hunter/scripts/email_verifier.py | 42 ++++++++++++ .../hunter/scripts/test_connection.py | 40 ++++++++++++ 6 files changed, 278 insertions(+) create mode 100644 integrations/hunter/manifest.yaml create mode 100644 integrations/hunter/scripts/account.py create mode 100644 integrations/hunter/scripts/domain_search.py create mode 100644 integrations/hunter/scripts/email_finder.py create mode 100644 integrations/hunter/scripts/email_verifier.py create mode 100644 integrations/hunter/scripts/test_connection.py diff --git a/integrations/hunter/manifest.yaml b/integrations/hunter/manifest.yaml new file mode 100644 index 0000000..d7d8ecd --- /dev/null +++ b/integrations/hunter/manifest.yaml @@ -0,0 +1,65 @@ +id: hunter +name: Hunter.io +version: 1.0.0 +description: "Hunter.io (API v2) — email OSINT: find the email addresses tied to a domain, find a specific person's email, verify an email's deliverability, and read account usage. API-key authentication; stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: domain search, email finder, email verifier, account." +category: enrichment + +# Per-instance configuration. The API key is sent as the 'api_key' query parameter. +config_schema: + properties: + api_key: + type: string + description: "Hunter.io API key" + x-soar-sensitive: true + required: + - api_key + +commands: + - id: domain_search + name: hunter-domain-search + description: "Find email addresses associated with a domain." + risk: read + inputs_schema: + properties: + domain: { type: string, description: "Domain name (e.g. example.com)" } + limit: { type: number, description: "Max results (default 10)" } + required: [domain] + outputs_schema: { properties: {} } + - id: email_finder + name: hunter-email-finder + description: "Find the most likely email address for a person at a domain." + risk: read + inputs_schema: + properties: + domain: { type: string, description: "Domain name" } + first_name: { type: string, description: "First name" } + last_name: { type: string, description: "Last name" } + required: [domain, first_name, last_name] + outputs_schema: { properties: {} } + - id: email_verifier + name: hunter-email-verifier + description: "Verify an email address's deliverability." + risk: read + inputs_schema: + properties: + email: { type: string, description: "Email address to verify" } + required: [email] + outputs_schema: { properties: {} } + - id: account + name: hunter-account + description: "Get account information and remaining request quota." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } + + - id: test_connection + name: hunter-test-connection + description: "Verify the API key (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/hunter/scripts/account.py b/integrations/hunter/scripts/account.py new file mode 100644 index 0000000..ed39c90 --- /dev/null +++ b/integrations/hunter/scripts/account.py @@ -0,0 +1,39 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://api.hunter.io/v2" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def request(path, cfg, params=None): + p = {k: v for k, v in (params or {}).items() if v not in (None, "")} + p["api_key"] = str(cfg.get("api_key", "")) + url = BASE + path + "?" + urllib.parse.urlencode(p) + req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + return request("/account", cfg) + + +_run(main) diff --git a/integrations/hunter/scripts/domain_search.py b/integrations/hunter/scripts/domain_search.py new file mode 100644 index 0000000..961e960 --- /dev/null +++ b/integrations/hunter/scripts/domain_search.py @@ -0,0 +1,44 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://api.hunter.io/v2" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def request(path, cfg, params=None): + p = {k: v for k, v in (params or {}).items() if v not in (None, "")} + p["api_key"] = str(cfg.get("api_key", "")) + url = BASE + path + "?" + urllib.parse.urlencode(p) + req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + domain = inputs.get("domain") + if not domain: + raise Exception("domain is required") + limit = inputs.get("limit") + limit = int(limit) if limit not in (None, "") else 10 + return request("/domain-search", cfg, params={"domain": domain, "limit": int(limit or 10)}) + + +_run(main) diff --git a/integrations/hunter/scripts/email_finder.py b/integrations/hunter/scripts/email_finder.py new file mode 100644 index 0000000..1b6910e --- /dev/null +++ b/integrations/hunter/scripts/email_finder.py @@ -0,0 +1,48 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://api.hunter.io/v2" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def request(path, cfg, params=None): + p = {k: v for k, v in (params or {}).items() if v not in (None, "")} + p["api_key"] = str(cfg.get("api_key", "")) + url = BASE + path + "?" + urllib.parse.urlencode(p) + req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + domain = inputs.get("domain") + if not domain: + raise Exception("domain is required") + first_name = inputs.get("first_name") + if not first_name: + raise Exception("first_name is required") + last_name = inputs.get("last_name") + if not last_name: + raise Exception("last_name is required") + return request("/email-finder", cfg, params={"domain": domain, "first_name": first_name, "last_name": last_name}) + + +_run(main) diff --git a/integrations/hunter/scripts/email_verifier.py b/integrations/hunter/scripts/email_verifier.py new file mode 100644 index 0000000..2f82710 --- /dev/null +++ b/integrations/hunter/scripts/email_verifier.py @@ -0,0 +1,42 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://api.hunter.io/v2" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def request(path, cfg, params=None): + p = {k: v for k, v in (params or {}).items() if v not in (None, "")} + p["api_key"] = str(cfg.get("api_key", "")) + url = BASE + path + "?" + urllib.parse.urlencode(p) + req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + email = inputs.get("email") + if not email: + raise Exception("email is required") + return request("/email-verifier", cfg, params={"email": email}) + + +_run(main) diff --git a/integrations/hunter/scripts/test_connection.py b/integrations/hunter/scripts/test_connection.py new file mode 100644 index 0000000..90379cd --- /dev/null +++ b/integrations/hunter/scripts/test_connection.py @@ -0,0 +1,40 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://api.hunter.io/v2" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def request(path, cfg, params=None): + p = {k: v for k, v in (params or {}).items() if v not in (None, "")} + p["api_key"] = str(cfg.get("api_key", "")) + url = BASE + path + "?" + urllib.parse.urlencode(p) + req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + request("/account", cfg) + return {"ok": True} + + +_run(main)