Introduction
git2mail extracts the email addresses a GitHub account exposes through its public activity: repository commits, profile events, and repository search. It comes in two shapes: a command-line tool, where you type commands in a terminal (the text window on your computer: Terminal on Mac and Linux, PowerShell on Windows), and an anonymous web scan - a browser page, no account and no install, capped at GitHub’s anonymous quota (its allowance for unauthenticated calls) - described on the project site.
Responsible use. git2mail collects only publicly available data through the GitHub REST API. Use it solely for lawful, authorized OSINT research, in accordance with the GitHub Terms of Service and applicable laws: email addresses are personal data in many jurisdictions, and the
results/and report files this tool writes hold them. The handling duties are covered in detail in Data quality, prevention & limitations.
git2mail scan --url <github URL> [--token …|--token-file …] [--json <path>]
git2mail search --query <q> [--language <l>] [--limit N] [--include-forks]
[--token …|--token-file …] [--json <path>]
git2mail email <address> [--token …|--token-file …] [--json <path>]
git2mail emails <file> [--token …|--token-file …] [--json <path>]
git2mail user <username> [--variant-pattern <p>] [--include-forks]
[--token …|--token-file …] [--json <path>]
git2mail org <org> [--limit N] [--include-forks]
[--token …|--token-file …] [--json <path>]
How to read the commands above: text in
[square brackets]is optional,|means “or”, and<lowercase words>stand for values you fill in: never type the brackets themselves.
Forked repositories are skipped by default on user, org and search (their
commit history mirrors the upstream project’s); --include-forks scans
them too.
search also accepts filter flags: --stars, --forks, --size,
--created, --pushed, --license, --topic, --user and --org, with
concrete examples in GitHub API usage and git2mail search --help.
scan accepts a repository URL or a profile URL; search runs a
repository search query and scans every result. email and emails
reverse one address (or a list) into GitHub accounts; user investigates
a profile (identity history, external contributions, generated email
variants, secondary accounts, SSH keys); org investigates an
organization’s members and repositories. The investigation commands (user, email, emails, org) report on
screen like the scan commands, and through the opt-in JSON report
(--json <path>) when you want to save them (see Results).
What is in this guide
- Scanning: scan & search - the two core subcommands: URL rules, keyword search, advanced qualifiers, scripting notes.
- User investigation - the
usersubcommand: identity history via the noreply technique, email variants, secondary accounts, SSH keys, external contributions. - Organization investigation - the
orgsubcommand: members, repositories, per-repository emails. - Reverse email lookup - the
email/emailssubcommands: the candidate-search heuristic, confidence grades, bulk mode. - Reports & files on disk - terminal output, exit codes, JSON report shapes, developer correlation, result file layout, sanitization and permissions.
- Data quality, prevention & limitations - what the findings can prove, how to harden your own exposure, coverage limits.
- GitHub API usage - requests, pagination, egress (proxies and Tor), rate-limit semantics, caps, search behavior.
Installation
The easiest way: download the ready-made program from the releases page: no installation needed. If you already have the Rust toolchain, you can instead install from crates.io:
cargo install git2mail --locked
Building from source needs the Rust toolchain plus cmake, a C compiler
and pkg-config (the bundled TLS library compiles C code; on macOS,
start with xcode-select --install then brew install cmake pkg-config).
Running the downloaded binary
Pick the file for your computer: git2mail-windows-v<version>.exe
(Windows), git2mail-macos-v<version> (Mac, Apple Silicon) or
git2mail-linux-static-v<version> (Linux; “static” means one single
file, nothing else to install). The builds are signed and logged by
GitHub’s artifact attestation (a receipt GitHub generates at build time,
proving this exact file came from this repository’s official build
pipeline); verifying requires the GitHub CLI gh:
gh attestation verify --repo extiop/git2mail <file>, as described in
each release’s notes. Replace <version> below with the version you
downloaded, without the leading v.
Then open a terminal in your Downloads folder and run the program from there:
- Windows: open the Downloads folder in File Explorer, type
powershellin the address bar and press Enter, then run the program with its real file name, for example./git2mail-windows-v0.8.0.exe scan --url https://github.com/octocat/Hello-World(a small repository: the anonymous quickstart in the README completes on it). If Windows shows a “Windows protected your PC” screen, first verify the file’s attestation (gh attestation verify --repo extiop/git2mail <file>, with the GitHub CLIghinstalled), then click More info and Run anyway (the program is not digitally signed). - Mac: in Terminal, type
cd ~/Downloadsthenchmod +x git2mail-macos-*(make it executable) and run a first scan, for example./git2mail-macos-v0.8.0 scan --url https://github.com/octocat/Hello-World. Running the file bare prints the help and exits. If the terminal instead printszsh: killedwith no dialog, macOS silently refused the quarantined file: clear the block withxattr -d com.apple.quarantine git2mail-macos-*and run it again. If macOS says it “cannot be opened because it is from an unidentified developer”: right-click the file, choose Open then Open again, or clear the block with the samexattrcommand. The downloaded program is built for Apple Silicon Macs; on an Intel Mac, build from source instead (above). - Linux:
chmod +x git2mail-linux-static-*then run a first scan, for example./git2mail-linux-static-v0.8.0 scan --url https://github.com/octocat/Hello-World.
The tool is not on Homebrew: get the release binary (above) or build from source with Cargo.
Tokens
Getting a token
Create a classic token at https://github.com/settings/tokens with no scopes at all (reading public data needs none) or a fine-grained token with “Public repositories (read-only)”.
tokens.example.json shows the file format --token-file expects: one
JSON object mapping a numeric index ("0", "1", …) to a token
string.
Authentication is optional. With no token, requests are anonymous and
subject to GitHub’s low anonymous rate limits. Tokens come from --token,
or from a JSON file passed to --token-file (see
tokens.example.json);
the format of each token is validated at startup and a token that fails validation
aborts the run before any request is sent.
All tokens are managed by a token pool that allocates them round-robin and reacts to rate limiting; GitHub API usage specifies the exact behavior.
For unattended jobs (CI, cron), write the token to a 0600 file at
runtime and pass it with --token-file
(( umask 077; printf '{"0": "%s"}' "$GH_TOKEN" > tokens.json ); the
umask is what makes the file 0600 when it is created; if tokens.json
already exists, chmod 600 tokens.json instead), rather than leaking it
through a process list.
Interrupting a run
Ctrl-C aborts the run immediately: nothing is written, not even a partial report, so a truncated scan can never look complete. Re-run the same command to start over.
Design principles
- GitHub REST API only. No HTML scraping, no binary git dependency: every fact git2mail reports comes from the documented API.
- Minimal dependencies. Everything doable with
stdis done withstd; every dependency is taken withdefault-features = falseand explicit features. Errors are hand-made (an#[non_exhaustive] enumwith explicitFromconversions), panics never occur on the runtime path, and the test suite runs fully offline against a local HTTP mock. - Blocking I/O with bounded parallelism. Scans use
reqwest::blockingoverstdthreads; the multi-repository fan-outs (the keyword search and the org scan, shared with the bulk reverse lookup) spawnmin(token_count, work_count)workers when tokens are configured, each dedicated the next live token (retired or parked tokens are skipped), draining a shared queue; an anonymous run uses a single shared-quota worker, and if every configured token is already dead the run still fails loudly. No async runtime.
Scanning: scan & search
The two core subcommands recover email addresses from GitHub activity:
scan targets one repository or one profile, search runs a keyword
search and scans every matching repository.
Scanning a repository or a profile
git2mail scan --url https://github.com/denoland/deno
git2mail scan --url https://github.com/denoland
Use a token to authenticate and fetch many more commits:
git2mail scan --url "$TARGET_URL" --token "$YOUR_TOKEN"
A token passed with --token stays saved on your computer in the record
of commands you have typed, and other programs running at the same time
can see it: prefer --token-file, which reads the token(s) from a file
instead. When both are provided, --token wins for that run and a
warning is logged (a whitespace-only --token counts as absent and falls
back to --token-file).
If you don’t have a token yet, you can still try the tool: simply leave
--token out. GitHub then allows only about 60 requests per hour, so a
token is recommended for any real scan.
Repository URL rules
The repository URL must follow the following format:
git://github.com/some-user/my-repo[.git]
git@github.com:some-user/my-repo[.git]
https://github.com/some-user/my-repo[.git]
ssh://git@github.com/group/app[.git]
- Every URL must point at
github.com(orwww.github.com); any other host is rejected as an invalid URL before the scan runs. The API base is GitHub’s, so a URL pointing at another server (a GitLab or Gitea remote, for instance) would otherwise be silently scanned against the github.com namesake. - A trailing
.gitsuffix is kept verbatim as part of the repository name: it is passed through to the GitHub API request and included in the output file name (results/repository/some-user_my-repo.git.log, the.logextension appended after the passthrough name). Since the GitHub API only resolves repository names without the suffix (GET /repos/some-user/my-repo.gitanswers 404, verified live), a scan with a.gitURL always fails with “GitHub resource not found”: the suffix documents a passthrough guarantee, not a usable input form: drop it before scanning. - A trailing
/on the URL is a leftover from copy-pasting, never part of the repository name: it is trimmed before the request (https://github.com/some-user/my-repo/targetssome-user/my-repo, not the guaranteed-404my-repo%2F). A link copied from a file browsing page (/tree/<branch>/…) targets a subfolder, not the repository root: paste the repository root URL instead. The other github.com page-navigation leftovers from copy-pasting (?tab=repositories,?plain=1#L12,#readme…) are stripped the same way, since no GitHub login or repository name contains?or#. The URL scheme is case-insensitive too:HTTPS://GITHUB.COM/some-user/my-repoparses exactly like its lowercase form (login/repository case is preserved). - A profile URL has no name after its separator to trim, so a trailing
/there is rejected as an invalid URL.
The profile URL must follow the following format:
git://github.com/some-user
git@github.com:some-user
https://github.com/some-user
ssh://git@github.com/some-user
Result paths and provenance
With several tokens, make your own copy of tokens.example.json with
your tokens in it, to look through many more commits:
git2mail scan --url "$TARGET_URL" --token-file "$YOUR_TOKEN_FILE"
With dummy values:
git2mail scan --url https://github.com/denoland/deno --token-file tokens.json
In this mode, your results will be stored per profile with the following
relative path, from your current working directory:
results/profile/$author.log. If a repository is defined, it will be at
results/repository/$author_$repository.log. A name the tool had to
change (sanitized) carries a 16-hex-digit suffix derived from the
original name, so two different targets whose names collapse onto the
same sanitized file name cannot silently overwrite each other on a
case-sensitive filesystem; on a case-insensitive one (macOS, Windows),
two targets differing only by case share a file name. An unchanged name
keeps its exact file name, and the exact target is always printed with
every report. The run always prints the real path of every file it
writes. The JSON report records when it ran (generated_at), which
version produced it (tool_version) and, on a keyword search, the exact
query, language filter and qualifiers used (request); qualifiers are
GitHub’s key:value search filters such as stars:>100, so two runs
with different qualifiers never write indistinguishable reports. Archive
the full command line next to the report for complete reproducibility
(--variant-pattern templates and file paths are not part of the
report).
To also get a JSON report (a structured text file you can open in any text editor, and other software can read) in addition to the on-screen results:
git2mail scan --url "$TARGET_URL" --json "$YOUR_REPORT_PATH"
Result files and JSON reports are written to a temporary file first, then renamed, so a file is never left half-written even if you open it while a scan is still running.
The profile events timeline cap
GitHub caps the profile events timeline at 300 events (its documented API ceiling): the scan never walks past the cap and a warning says so when the cap cuts a prolific profile’s listing, instead of the events stage failing on the out-of-window request. When the events hold no email, the scan falls back to the target’s repositories’ commits, as always.
Search: metadata as a keyword
You want to search, for instance, for some Rust developers that create
nmap related tools:
git2mail search --query nmap --language rust --token-file tokens.json
You can also limit the number of repositories scanned this way, for instance:
git2mail search --query nmap --language rust --token-file tokens.json --limit 5
Without --limit, the search defaults to scanning 5 repositories.
In this mode, your results will be stored per query with the following
relative path, from your current working directory:
results/keyword/$query.log. If a language is defined, it will be at
results/keyword/$query_$language.log. As everywhere else, a name the
tool had to change (sanitized) carries a 16-hex-digit suffix derived
from the original name; the run always prints the real path of every
file it writes.
GitHub’s repository search includes forks: like the profile and
organization investigations, forked repositories are skipped with a
warning (their commit history mirrors the upstream project’s, so
scanning them would report other projects’ developers under the fork’s
name). Pass --include-forks to scan them too when the forks themselves
are your subject.
A keyword search scans several repositories, so its JSON report also
breaks the findings down per project: per_repository maps each
repository’s owner/name to the emails its commits exposed (only the
repositories where at least one email showed up are listed, and the key
is omitted entirely when no repository produced one), and the request
object records the exact query, language filter and qualifiers that
produced the report (with generated_at and tool_version, two runs
with different qualifiers never write indistinguishable reports).
Advanced search qualifiers
The search subcommand also accepts the optional qualifiers of
GitHub’s advanced search. They all
compose with --query, --language and --limit, and only the ones
you provide are appended to the GitHub query:
| Flag | Qualifier | Example values |
|---|---|---|
--stars <RANGE> | stars: | >100, >=50, 10..50, 100..* |
--forks <RANGE> | forks: | >10, 5..20 |
--size <RANGE> | size: | >1000, 100..5000 |
--created <DATE_RANGE> | created: | >2020-01-01, >=2020, 2019-01-01..2020-01-01 |
--pushed <DATE_RANGE> | pushed: | >2024-01-01, >=2023 |
--license <ID> | license: | mit, apache-2.0 |
--topic <NAME> | topic: | osint, recon |
--user <LOGIN> | user: | octocat (mutually exclusive with --org) |
--org <LOGIN> | org: | acme (mutually exclusive with --user) |
Quote range values that contain > or < so the shell does not read
them as redirections: --stars '>100'.
For instance, to scan well-established MIT-licensed Rust repositories of one organization, recently pushed:
git2mail search --query nmap --language rust --stars '>100' --license mit --org acme --pushed '>2024-01-01' --token-file tokens.json
To target one user’s repositories on a topic:
git2mail search --query nmap --topic osint --user octocat --token-file tokens.json
--user and --org are mutually exclusive (the GitHub search API
accepts only one owner filter). Values are validated client-side before
any request: an obviously malformed range or date (say --stars abc or
--created 2020-13-01) fails fast with a clear invalid search filter
error instead of silently returning no result from the API. Count and
date ranges follow the GitHub search range syntax (>, >=, <, <=,
start..end, with * for an open end); dates accept the YYYY,
YYYY-MM and YYYY-MM-DD granularities. An inverted range
(--stars 50..10, --created 2020-01-01..2019-01-01) is well-formed
but rejected client-side like every other malformed value; for dates
this matters because GitHub itself does not reject an inverted range: it
silently swaps the sides and quietly covers the opposite window. The
stricter name rules are validated client-side too: a --user/--org
value must be a valid GitHub login (ASCII letters, digits or hyphens; a
login with . or _ would silently match nothing) and a
--license/--topic value must hold at least one alphanumeric
character (a separator-only ... or -- would silently match
nothing). Only one --topic value is accepted per run; to combine
topics, pass them in the query itself
(--query "topic:osint topic:recon"). The repository listing itself is
capped at GitHub’s documented 1000-result search ceiling: the
over-ceiling page is never requested (the API answers it with a raw
422), so a query matching more than 1000 repositories scans at most the
first 1000. The qualifiers are not part of the output file name:
results are stored per query/language, so two runs differing only by
their qualifiers write to the same
results/keyword/$query_$language.log file, and the last run overwrites
the previous one. For a systematic sweep over qualifiers, give each run
its own --json report-<qualifier>.json path: the text file is the
working list, the JSON reports are the archive.
Scripting notes
- Exit code
0means the run completed,1that it failed,2that the command line itself was invalid, and3that the run completed but found nothing (an emptyscan/searchresult): branch on3to tell “the target is live but carries no email” apart from a real failure. - Only warnings and the report lines reach the terminal, and warnings
go to stderr: a pipeline can detect a partial run by merging the
streams and matching the
[!]marker warnings carry, for examplegit2mail … 2>&1 | grep '^\[!\]'(PowerShell:git2mail … 2>&1 | Select-String '^\[!\]'). - The exit codes and the on-disk layout are documented in Reports & files on disk; see also Data quality, prevention & limitations for what the reports can and cannot prove.
Combining with other OSINT tools
This tool can be combined with other OSINT tooling. For instance, you can use git2mail to retrieve Gmail addresses, then investigate some of them further with GHunt (for lawful research only, as everywhere in this guide).
User investigation (user)
The user subcommand investigates one GitHub profile end to end, 100%
API (no clone, no HTML scraping):
git2mail user "jane-doe" --token "$YOUR_TOKEN"
This investigation walks many pages: a token is strongly recommended (an anonymous account gets only 60 requests per hour and the walk (the page-by-page listing traversal) aborts when the quota is spent).
It accepts the shared --token/--token-file/--json flags and
reports, both in the terminal log and in the opt-in JSON report. The
observed email addresses are also saved to results/user/<username>.log,
one per line, the same paste-able file the scan and org subcommands
write.
What the investigation collects
- Profile: the account fields that carry OSINT value (name, company,
blog, location, public email, bio, creation date, account type,
Twitter/X handle and public repository count). An organization target
is rejected with a clear error pointing at the
orgsubcommand. - Usernames/names history: the noreply technique: GitHub hides
users’ real email in commits behind an alias like
12345+jane@users.noreply.github.com, and the number in that alias is the account’s ID, so matching it exposes usernames the account used in the past. The username in the alias is written by the commit author’s Git configuration, not by GitHub: treat it as strong evidence, not as a verified fact. The target’s own repositories’ commits are scanned, and every author/committer email shaped<id>+<username>@users.noreply.github.comwhose numericidequals the target’s reveals a username the account used in the past (with the commit names seen with it and the repositories where it appeared). The older pre-2017 form<id>@users.noreply.github.comattributes the current username only. - Emails: every email used on the target’s repositories, with the
names and repositories where each was seen. Noreply addresses are
excluded: they feed the histories instead, and a malformed noreply
payload (a non-numeric id…) is dropped entirely. Forked repositories
are skipped with a warning: their commit history mirrors the upstream
project’s, so scanning them would report other projects’ developers.
Pass
--include-forksto scan them too when the forks themselves are your subject. - Variants: the likely email spellings
{first}.{last},{first}{last},{initial}{last},{first}_{last},{initial}_{last},{first},{last},{initial}.{last}applied to the first/last names extracted from the profile name and the commit names seen with the target’s noreply id (a name that appears only with a real, non-noreply email does not feed the guessing), combined with the domains deduced from the emails already found (noreply excluded). Variants actually observed in the commits are reported asmatched(deduplicated by email); the others asgenerated(candidates to check elsewhere, not findings). Names are lowercased and accents removed (é→e, plusß→ss,æ→ae,œ→oe,ø→o); names in other alphabets are kept as written. The candidate names and domains are ranked by raw sighting weight and capped (at most 64 name pairs and 64 domains are expanded, the generated list at 1000 candidates, with warnings), so commit-forged metadata cannot drown the real ones. Beyond the eight built-in patterns,--variant-pattern <PATTERN>(repeatable, at most 64 patterns) probes your own conventions, e.g.git2mail user janedoe --variant-pattern '{last}{first}'; the placeholders are{first},{last}and{initial}(the domain is not part of the pattern), only printable ASCII is allowed (whitespace,@, control and zero-width characters are rejected client-side), the expansion is lowercased so the literal part matches observed emails case-insensitively like the built-ins, the custom patterns are applied after the built-ins (deduplicated against them) and are checked against the commit data fetched from the API like every other candidate. On a broad target the built-ins alone can fill the 1000-candidate generated list; a warning then names the custom patterns as possibly under-listed (matched custom candidates are always reported). To check a generated candidate, rungit2mail email <candidate>. - Local identities (machine-local addresses): emails like
jeanpierre@My-Computer.local,jeanpierre@box.lanor a single-label domain likejeanpierre@mycomputer. This is a heuristic: it flags the documented mDNS/workgroup conventions and dot-less domains only, and a false positive merely adds one candidate to thelocal_identitieslist. - Potential secondary accounts (heuristics, reported with their
evidence): an email recurring across two or more of the target’s
repositories whose names never overlap the target’s own names
(
co-author), and noreply pairs (id+usernameor legacy<id>form) whose id differs from the target’s but whose username matches a known target username (noreply-username) or whose commit names match a known target name (noreply-name). The web-flow identity GitHub stamps on commits made through its UI (namegithub, addressnoreply@github.com) is never reported as a secondary account: it is automation, not a person (the email itself stays in the emails list). - SSH keys: the account’s public keys (
GET /users/{u}/keys, paginated). The public endpoint exposes the key id and material only: the user-set title and creation date exist on the authenticated self listing, not here. - External contributions: commits authored by the target outside
their own repositories
(
search/commits q=author:<user> -user:<user>, paginated, capped at GitHub’s documented 1000-result search ceiling), attributed to the repositories found.
Like every repository scan, each target repository’s commit walk covers the full default-branch history (the branch a project presents as its main line) with no local budget: the walk ends when GitHub’s listing ends, so the report never silently pretends it saw only part of a history.
JSON report shape
With --json, the enriched report carries the documented shape:
{
"generated_at": "2026-02-14T18:22:33Z",
"tool_version": "0.8.0",
"profile": { "login": "jane-doe", "id": 42, "name": "Jane Dupont", "...": null },
"usernames_history": [{ "username": "oldname", "names": ["jane d."], "repos": ["jane/old"] }],
"names_history": [{ "name": "jane dupont", "repos": ["jane/old"] }],
"emails": [{ "email": "jane@example.com", "names": ["jane dupont"], "repos": ["jane/one"] }],
"variants": {
"matched": [{ "email": "jane.dupont@acme.com", "pattern": "first.last", "domain": "acme.com" }],
"generated": ["janedupont@acme.com", "jdupont@acme.com"]
},
"secondary_accounts": [{ "kind": "co-author", "identity": "co@acme.com", "names": ["co worker"], "repos": ["jane/one", "jane/two"] }],
"ssh_keys": [{ "id": 1, "key": "ssh-ed25519 AAAA…" }],
"external_contributions": [{ "repository": "acme/lib", "url": "https://github.com/acme/lib", "commits": 3 }],
"local_identities": [{ "email": "jeanpierre@My-Computer.local", "names": [], "repos": ["jane/one"] }]
}
(generated_at and tool_version open the block: every report carries
them.)
API-only deltas
Two API-only deltas are assumed and documented (the clone-based tools iterate all branches):
- the commit walk covers the default branch only: the API can address
other branches and tags (a
shaparameter), but a per-branch walk multiplies the request cost by the branch count and is not attempted, so the histories and emails are the ones visible on the default branches; - the noreply history only reveals usernames for targets that committed
at least once with the
id+username@users.noreply.github.comform, and only usernames matching GitHub’s full username shape (1-39 ASCII letters, digits or hyphens, without a leading/trailing hyphen or consecutive hyphens): a commit email carrying a free-form or impossible-shape username payload falls back to the legacy id-only handling.
A rate-limit or transport failure aborts the investigation; only a repository that is private, deleted or empty is skipped with a warning, so the report can never silently miss whole repositories.
Organization investigation (org)
The org subcommand investigates one GitHub organization: its public
members, its public repositories and the developers’ emails found
scanning them.
git2mail org "acme" --token-file tokens.json
As with the user investigation, a token is strongly recommended: an org
scan walks every listed repository and aborts on a spent anonymous
quota. Forked repositories are skipped with a warning (like the
user investigation): their commit history mirrors the upstream
project’s, so scanning them would report other projects’ developers.
Pass --include-forks to scan them too when the forks themselves are
your subject.
It accepts the shared --token/--token-file/--json flags plus an
optional --limit N capping the number of scanned repositories (all of
them by default), and reports, both in the terminal log (ORG / MEMBERS /
REPOSITORIES / EMAILS) and in the opt-in JSON report.
What the investigation collects
- Org: the
GET /orgs/{org}fields that carry OSINT value (login, name, blog). A missing organization is a clean not-found error. - Members: the organization’s publicly visible member usernames
(just names on GitHub, no contact details; many companies hide this
list entirely, and the endpoint only lists members who chose to
publicize: a short list is not a malfunction). The emails below come
from the repositories’ commits, not from these members. To investigate
one member, run
git2mail user <username>. - Repositories: the public repositories (
GET /orgs/{org}/repos, paginated). - Emails: the developers’ emails found scanning the organization’s
repositories’ commits with the same worker-per-token parallelism as
the parallel
search, reported per repository and globally (noreply addresses excluded, like every scan). The found addresses are also saved toresults/org/<org>.log, one per line, the same paste-able file the scan and user subcommands write. For an exposure audit, theper_repositorymap is the finding: it tells you which repository leaks which addresses; attribute a person to an address withgit2mail email <address>before contacting them.
JSON report shape
With --json, the report carries the documented shape:
{
"generated_at": "2026-02-14T18:40:12Z",
"tool_version": "0.8.0",
"org": { "login": "acme", "name": "Acme Corp", "blog": "https://acme.example" },
"members": ["alice", "bob"],
"repositories": ["one", "two"],
"emails": {
"per_repository": { "acme/one": ["alice@acme.com"], "acme/two": ["bob@acme.com"] },
"all": ["alice@acme.com", "bob@acme.com"]
},
"correlation": {
"authors": [
{
"author": "Alice", "emails": ["alice@acme.com"], "total_hits": 1,
"repos": [{ "name": "acme/one", "language": null }],
"first_seen_repo": "acme/one",
"last_commit_date": "2024-06-01T12:00:00Z"
}
]
},
"scanned": { "repos": 2, "pages": 9 }
}
per_repository is keyed by the owner/name full names the scan
reports, and only lists the repositories where at least one email showed
up; scanned.pages counts the successful API requests of the whole
investigation (org info, members, repositories listing and the workers’
commit pages). The correlation object follows the same shape as the
scan and search reports, aggregating the developers found across the
organization’s repositories. A repository that is private, deleted or
empty is skipped with a warning; a rate-limit or transport failure
aborts the investigation, and so does a dedicated token dying mid-scan
(a revoked token is not a spent quota: the run aborts with the
token’s real error instead of writing a complete-looking report).
Reverse email lookup (email & emails)
The email subcommand traces one email address back to its GitHub
account; the emails subcommand runs the same lookup in bulk over the
token pool.
git2mail email "jane.dupont@acme.com" --token "$YOUR_TOKEN"
git2mail emails addresses.txt --token-file tokens.json --json bulk.json
A token is strongly recommended here too: one lookup walks the full
default-branch history of every candidate repository (until a
commit-author match ends the lookup early), far beyond the anonymous
60-requests-per-hour budget.
GitHub exposes no email search API, so this subcommand is a documented heuristic - a stated, test-pinned search strategy, not an exact index - 100% API (no clone, no scraping).
How the heuristic works
- Candidate repositories: the repository search endpoint is queried
with the tokens of the local part (split on
.,_,-and+, sojane.dupont→jane dupont), with the raw local part when it differs (sojanedupontis searched too) and with the domain itself (acme.com); at most three queries. The repositories whose name, description or topics mention these tokens become candidates: every candidate the search serves is verified, with no local cap. - Commit verification: each candidate repository’s (default-branch)
commits are scanned for a commit whose author email is exactly the
target: the local part case-insensitively, the domain matched exactly
(no subdomain look-alike), and the lookup stops early once the
strongest attribution is found. The commits listing is never narrowed
by the API
authorparameter: the commit author login is not necessarily the email local part, and a narrowing would silently drop the target’s own commits. - Account attribution: a matching commit whose API payload embeds a
GitHub author object resolves the account with the
commit-authorconfidence (login, numeric id, repository and commit sha as evidence). When the author object is absent (deleted account), the repository owner is only a weak candidate, reported with therepo-owner-weakconfidence and anullid: a presumption, never overstated.
Anti-false-positive rules: a GitHub noreply address
(@users.noreply.github.com) is rejected upfront with a clear error (it
never carries a real mailbox; the user subcommand’s noreply technique
covers those), a malformed address is rejected client-side before any
request, the email match is exact, and the confidence field states
only what the API payload actually proved. An unmatched address is
reported as unmatched (a finding, not an error).
Single-address JSON report shape
With --json, the report carries the documented shape:
{
"email": "jane.dupont@acme.com",
"account": { "login": "janedoe", "id": 42, "confidence": "commit-author" },
"evidence": { "repository": "acme/lib", "commit_sha": "abc123", "author_email": "jane.dupont@acme.com" },
"candidates": [{ "login": "janedoe", "id": 42, "confidence": "commit-author" }]
}
Limitations of the heuristic
Documented API-only deltas:
- the candidate search can only surface repositories whose name, description or topics mention the local-part tokens or the domain (forked candidates are kept: the lookup wants any repository carrying the address): an email never used in a public commit of one of those candidates cannot be found;
- the commit walk covers the default branch only (per-branch walking multiplies the request cost and is not attempted);
- the
repo-owner-weakattribution presumes the repository owner is the login behind the matching commit: the commit carries no GitHub author object, so nothing in the payload actually proves it, and a repository owner may be an organization rather than a user, so resolve the account type before acting on a weak attribution.
Bulk mode (emails)
The file holds one address per line (# comments and blank lines
ignored; a leading UTF-8 BOM, as written by Windows editors, is
stripped); duplicated addresses are deduplicated and noreply or
malformed lines are skipped with a warning (they cannot be reversed,
they are not scan failures). The addresses are processed with the same
worker-per-token parallelism as the parallel search, and the report
keeps the file order with a matched/unmatched/skipped summary (total
counts only processed addresses):
{
"generated_at": "2026-02-14T18:22:33Z",
"tool_version": "0.8.0",
"results": [
{
"generated_at": "2026-02-14T18:22:41Z",
"tool_version": "0.8.0",
"email": "jane.dupont@acme.com", "account": null, "evidence": null, "candidates": []
}
],
"skipped_addresses": [],
"summary": { "total": 1, "matched": 0, "unmatched": 1, "skipped": 0 }
}
Every report also carries generated_at (the UTC timestamp of the run)
and tool_version (shown here); the one-address report of
git2mail email carries them too, and so does every entry of the bulk
results list (the moment its own lookup finished).
A worker whose dedicated token died (spent quota or rejected token)
stops pulling work while the other tokens keep draining the queue; any
other worker failure aborts the bulk run. The addresses a stopped worker
never reached are never counted as a verified no-match: they are absent
from results, listed in skipped_addresses (file order, so re-running
the interrupted lookups needs no diff of two files), counted in
summary.skipped and warned about in the log. “Skipped” names two
different things: the read-time skips (noreply/malformed lines, warned
about when the file is read) never enter the pipeline and are not
counted in summary.skipped, which counts only the work a stopped
worker never reached.
Results
Results are always saved in a results folder inside the folder you ran the command from, not next to the git2mail program itself (./results/…). The text output is skipped when a scan yields no
email; the --json report (if requested) is still written, with empty
emails. These files hold personal data (real people’s email addresses):
store and share them with the same care as the data itself, delete them
when you are done, and add results/ to your .gitignore if you run the
tool inside a git repository.
Terminal markers
Report lines start with one of four markers: [+] (a finding or a success),
[!] (a warning or an uncertain result), [-] (an error or an empty result)
and [.] (an informational listing line: the email report’s candidate
accounts, the user report’s secondary-account listings). The [!] warning lines on stderr are a stable,
grep-able signal (^\[!\]): a pipeline can detect partial runs without
parsing the JSON.
Section headers like ============ ORG ============ and the ----
separators are skippable decoration: screen readers and scripts can jump
straight to the marked lines.
The scan and search terminal reports end with a summary of the
top 5 authors by commit count (their dominant language included); the
complete author list is in the JSON report’s correlation.authors.
Exit codes
0: the run completed (an empty result is still a completed run: email
with no match, org with no public email). 1: the run failed (a
network, authentication or API error); a failed emails (bulk) run also
writes the partial report of the lookups it already paid for before
propagating the failure. 2: the command line itself is invalid (clap
argument error). 3: the run completed but found nothing (an empty
scan or search result: the target is live but carries no recoverable
email; the JSON report is still written when --json was requested), so
scripting can tell “live but empty” apart from a real failure.
Layout
| Scan | Path |
|---|---|
Repository (scan --url …/repo) | results/repository/<owner>[_<repo>].log |
Profile (scan --url https://github.com/<user>) | results/profile/<user>.log |
Search (search --query …) | results/keyword/<query>[_<language>].log |
User investigation (user <username>) | results/user/<username>.log |
Organization investigation (org <org>) | results/org/<org>.log |
Every text result file carries the .log extension so a double-click opens
it natively (Notepad, TextEdit, VS Code); the name before the extension is
the sanitized target name (see “File names” below). Each .log file is a
complete snapshot of its run, not an append-only log: a rerun replaces it
atomically.
Startup proxy check
Before the first request, the run resolves the environment’s proxy variables with the same precedence rules as the HTTP stack, mirrored conservatively: a value the check cannot vouch for is warned about rather than trusted. What is actually used is announced in the log, and a set-but-unusable variable is named with a loud warning instead of the stack’s silent fallback to a direct connection; proxy variable values are never logged (a proxy URL can carry embedded credentials), only names and schemes are. The full semantics (variables, schemes, Tor, caveats) live in Network egress.
Text format (default)
One email address per line, sorted case-insensitively, deduplicated across every scanned page and repository.
Addresses on the users.noreply.github.com domain (GitHub’s alias addresses, which are not real mailboxes) are excluded from the results.
JSON report (--json <path> or --json -)
Every report stamps its own provenance: generated_at (the UTC
timestamp of the run, YYYY-MM-DDTHH:MM:SSZ) and tool_version (the
git2mail --version that wrote it). A search report additionally
records the request that produced it (request.query, request.language
and every advanced qualifier actually provided), so two runs with
different qualifiers never write indistinguishable reports. The report
still does not know the full command line (a --variant-pattern list or
a token file path, for instance): archive the exact command next to the
report for full reproducibility. A --json path that is a symbolic link
is refused before the run (the atomic replace would destroy the link):
write to a timestamped file name and swap the symlink yourself.
--json <path> additionally writes a JSON report at the path you give (for example --json report.json). With --json -, the same JSON document is printed to stdout instead (only the JSON goes to stdout; the log stays on stderr), which makes the report pipeable into other tools.
{
"target": "<scan target>",
"mode": "repository|profile|search",
"request": { "query": "nmap", "language": "rust", "qualifiers": { "stars": ">100" } },
"emails": ["…"],
"per_repository": { "owner/repo": ["email@example.com"] },
"scanned": { "repos": 12, "pages": 30, "partial": false },
"generated_at": "2026-02-14T18:22:33Z",
"tool_version": "0.8.0",
"correlation": {
"authors": [
{
"author": "Alice",
"emails": ["alice@example.com"],
"repos": [
{ "name": "a/one", "language": "Rust" },
{ "name": "b/two", "language": null }
],
"languages": ["Rust", "Go"],
"first_seen_repo": "a/one",
"total_hits": 3,
"last_commit_date": "2024-06-01T12:00:00Z"
}
]
}
}
request appears only on a search report (the query provenance: the
qualifiers that were not provided are omitted, not null).
Forked repositories are skipped on the user, org and search subcommands (a warning names the
--include-forks opt-in when the skip fires). The reverse-email candidate search keeps forks: it
wants any repository carrying the address, including forks of the subject’s own work.
per_repository appears on the parallel scans (a keyword search): it
maps each repository’s owner/name to the emails found in its commits,
so a multi-repository run can be read per project without re-running it.
Only the repositories where at least one email showed up are listed, and
the key is omitted entirely when no repository produced one. The report is written after the text output, at the end of the scan. The
correlation block groups the emails by commit author: for each author it
lists every address seen in their commits, the repositories they appear in
(with the repository’s primary language; language is null for scan
runs, which do not spend an extra API call on it; only search fills it),
the languages ordered dominant
first (most commits, ties alphabetical), the repository where the author was first seen (with a parallel
multi-token scan this follows worker completion order and may vary between
runs), and the total number of hits. last_commit_date is the most
recent commit author date seen for the author (the raw ISO 8601 UTC stamp
of the API payload): a freshness indicator for the exposure (self-declared,
forgeable commit metadata, exactly like the author name - never a proof).
scan, search and org can also
export this same per-author report as a CSV spreadsheet with --csv <path> (one row per author: author_name, emails, repositories, dominant_language, commits, multi-valued cells joined with ;, RFC 4180
escaping so a hostile commit name cannot forge spreadsheet rows). A commit
without a detected (non-noreply) email is not
attributed; a commit with no author name is attributed under the email
itself.
scanned.partial is true when the scan stopped early (one of your tokens ran out or was refused mid-scan, so
some listed repositories were never checked) and the reported emails may be incomplete; the matching
warning names the flag in the log. A report with partial: true must never
be mistaken for a complete one. Scans that run from start to finish always
report partial: false (they fail instead of reporting incompletely);
other limits (the search limit) are announced by their
own warnings and do not set the flag.
OSINT reports (user, email, emails, org)
The investigation subcommands write a JSON report to the path given by
--json (org and user also write the same one-email-per-line text file
as the scan subcommands, at results/org/<org>.log and
results/user/<username>.log):
user: identity history, external contributions, generated email variants and the addresses they matched, potential secondary accounts, public SSH keys (full example blocks with every field in the user investigation guide);org: the report also carries acorrelationobject with the same per-author shape as the scan reports (person, emails, repositories and commit counts): the remediation view attributing an exposure to a person;email/emails: the reverse lookup, listing the candidate GitHub accounts with a confidence grade (commit-authorwhen a commit of the candidate account carries the looked-up address,repo-owner-weakwhen the address matches a repository it owns), the candidate accounts considered, the commit that proved the match (repository, sha, author email), and the GitHub account owning the address (heuristic: the search surfaces the candidate repositories, each verified over its full default-branch history, until acommit-authormatch ends the lookup early);orgadditionally writes a text fileresults/org/<org>.logwith one found email per line, ready to paste into a spreadsheet or outreach tool. Anemails(bulk) report carriesskipped_addresses(an empty list on a clean run): on an interrupted run it is the file-order list of the addresses a stopped worker never reached (thesummary.skippedcount is this list’s length), so re-running the interrupted lookups needs no diff of two files. Every report carriesgenerated_atandtool_version. The bulk run has no CSV deliverable (its report carries no per-author correlation): the--jsonreport is its file output. On the other subcommands (user,email,emails)--csvis accepted, warned about, and ignored.
Developer correlation
Every scan also aggregates its results per author: for each author found in the scanned commits, it correlates the attributes git2mail already collects: the emails they used, the repositories they were seen in with their primary language, the first repository where they appeared and their total commit count. Authors are sorted by commit count and each author’s languages are ordered dominant first, so the report answers “who are the main developers behind this topic, and what do they write in?”. With a parallel multi-token scan, the repository order (and the “first seen” field) follows worker completion and may vary between runs; the email sets and counts do not.
For a copy-paste list of addresses, use the plain-text results/ file
(one email per line, ready for a spreadsheet); the names, languages and
per-repository counts live in the JSON report, or as a --csv
spreadsheet (one row per developer: name, emails, repositories,
dominant language, commit count).
The report lives in the opt-in --json output, as a correlation object
added on top of the unchanged legacy fields (the plain-text result files
stay byte-identical):
git2mail search --query nmap --language rust --token-file tokens.json --json report.json
{
"target": "nmap",
"mode": "search",
"emails": ["alice@example.com"],
"scanned": { "repos": 2, "pages": 3, "partial": false },
"correlation": {
"authors": [
{
"author": "Alice",
"emails": ["alice@example.com"],
"repos": [
{ "name": "a/one", "language": "Rust" },
{ "name": "b/two", "language": "Go" }
],
"languages": ["Rust", "Go"],
"first_seen_repo": "a/one",
"total_hits": 3,
"last_commit_date": "2024-06-01T12:00:00Z"
}
]
}
}
scanned.partial is true when the parallel scan kept its findings
although a dedicated token died mid-scan (whole listed repositories may
then have been left unscanned): the JSON report keeps partial: true
even when the run itself exits 0.
The primary language comes free with the repository-search metadata, so
search runs attribute a language to every repository that has a
detected primary language (GitHub reports language: null when it has
none, and the report passes it through, like scan (repository/profile)
runs which do not spend an extra API call on it). The same author seen
in several repositories (across pages, workers or scan stages) is
merged: emails deduplicated, hit counts summed. The terminal log also
prints a short summary of the top 5 authors (by commit count) with their
dominant language; the full author list lives in the JSON report’s
correlation.authors, and the plain-text result files keep the legacy
one-email-per-line format, byte-identical.
File permissions
Result and report files hold personal data, so a FIRST publish creates
them readable by the owner only (0600 on Unix) instead of the usual
world-readable default; republishing preserves a mode you set on an
existing file (a tightened 0600 or a deliberately shared 0644).
File names
Scan targets become file names after sanitization:
- path separators and reserved characters (
/ \ : ? * " < > |) and control or invisible characters are replaced by_; the directory component is sanitized too; - trailing dots and spaces are stripped, and an empty name becomes
_; - Windows reserved device names (
CON,NUL,COM1, …), with or without extension, are prefixed with_(CON.txtbecomes_CON.txt); - a name the sanitizer changed carries a 16-hex-digit hash of the raw name (
a/bbecomesa_b-<hash>): distinct raw names that collapse onto the same sanitized form cannot silently overwrite each other (a rerun of the same query still refreshes its own file by design). An unchanged name keeps its exact file name, whatever its case; on a case-insensitive filesystem (macOS APFS, Windows NTFS) two targets differing only by case share one file name - the exact target is visible in every report line and in the JSON report; - a name longer than 200 bytes is cut on a character boundary and carries the same hash (taken over the full raw name, so two distinct long queries sharing a prefix stay distinct), and the write can no longer fail after the scan completed.
Data quality, prevention & limitations
What the reports can prove, how to protect yourself, and where the tool’s coverage ends.
Data quality
One’s email can be spoofed in commits: an address read in a commit
proves nothing about who owns the mailbox. Cross-check the confidence
grades of the email reports and the per-author summary of the
correlation report (--json) before drawing conclusions. To open a
cited piece of evidence, run git2mail email <address>: its report
carries the repository and the commit sha (the long identifier after
/commit/ in a GitHub URL) that proves the attribution: build the
commit URL as https://github.com/<owner>/<repo>/commit/<sha>. The
scan and search reports carry repositories and counts but no commit
sha, so the reverse lookup is the evidence step.
A note on pull requests: when a contributor’s pull request was
squash-merged (combined into one commit) through GitHub’s web interface, the squashed commit’s
author email is the contributor’s private
id+username@users.noreply.github.com alias (GitHub stamps its own
web-flow identity, noreply@github.com, as the committer). The
scan and search subcommands filter out the
users.noreply.github.com domain, so the squashed commit contributes
nothing to them: a prolific pull-request contributor can therefore stay
invisible to the scan and search subcommands; the user subcommand
recovers their identities through the commit history instead.
Prevention
Harden your account privacy by enabling these settings:
- Keep my email addresses private
- Block command line pushes that expose my email
These settings protect only future commits: an address you committed
with before stays in the repository’s history and clones. Run
git2mail user <your-username> on yourself for the full picture of
what this tool recovers about you (history, guessed addresses,
secondary accounts), and rewrite old history (for example with
git filter-repo) if you need it gone.
Limitations
This project is limited to GitHub repositories. The user investigation
reads both the commit author and committer emails when they
differ (the committer is a real second channel, rarely different but
sometimes the only one that leaks); the repository and keyword scans
read the commit author email.
Per-subcommand coverage deltas live in the page of the feature they constrain: the default-branch-only commit walk and the noreply-history preconditions in User investigation, the candidate-search visibility limits of the reverse lookup in Reverse email lookup, and the search ceilings in Scanning: scan & search.
GitHub API usage
Requests
- Every endpoint is queried with
per_page=100(the maximum page size). - The
Authorization: token <token>header is sent only when the request is bound to a token; anonymous requests omit it. - User agent and
Acceptheaders are set once, in the client.
Pagination
All listings (repository commits, profile events, profile repositories, search results) go through a single paginated loop:
- fetch page
n; - if the page is empty, the listing is complete;
- otherwise parse it and continue with page
n + 1.
Two guards bound the loop:
- Short-page guard. Listings fetched page by page (repository commits,
profile events, search results, the
userinvestigation’s identity listings, theemailcandidate search, and the commit verification of oneemailcandidate) stop when a page comes back underfilled, instead of walking into the error page GitHub serves past the real end of a listing. The simple listings (the profile fallback repository enumeration, the public repositories and keys of auser, organization members and repositories) end only when a page comes back empty. - Token-pool bound. Every page request participates in the rate-limit handling below; the loop can never spin: when every token is exhausted, the scan fails with an explicit error instead of retrying forever.
Network egress: proxies and Tor
The HTTP client honors the standard proxy environment variables with the same precedence rules the HTTP stack applies, and git2mail checks them at startup, announces the proxy actually used, and loudly warns instead of silently falling back when a configured proxy is unusable (the check mirrors the stack conservatively: a value it cannot vouch for is warned about, which only ever over-warns - never the reverse).
What the stack honors
HTTPS_PROXY(orhttps_proxy) is the primary variable for git2mail’s traffic: the tool is HTTPS-only, so anHTTP_PROXYvalue is never used for it (announced as an informational note, since it usually means a proxy is configured but scoped elsewhere).ALL_PROXY(orall_proxy) is the fallback, used only when the primary variable is absent or unusable.- Proxy schemes: the
httpandhttpsschemes are accepted case-insensitively (HTTP://proxy:8080works); thesocksschemes (socks5://,socks5h://,socks4://,socks4a://) are matched case-sensitively and lowercase-only -SOCKS5://is not recognized by the stack (a warning names it). A scheme-less bare authority is assumedhttp. - A set-but-empty variable counts as set and shadows its lowercase twin: the fallback (if any) is used instead - when no fallback exists, the run goes direct - and the warning names the outcome.
- A set
NO_PROXYcan bypass the proxy for some hosts: warned about when a proxy is in use. - A set
REQUEST_METHOD(the stack’s CGI marker) makes the stack discard every proxy variable: warned about when any proxy variable is set. - Proxy values are never logged: a proxy URL can carry embedded credentials.
Tor
Route a run through Tor with a socks5h URL, no flag needed:
HTTPS_PROXY=socks5h://127.0.0.1:9050 git2mail scan --url "$TARGET_URL" --token-file tokens.json
socks5h asks the proxy to resolve hostnames (verified against the
bundled HTTP stack: the socks5h scheme maps to proxy-side DNS
resolution); plain socks5 resolves hostnames locally, which leaks
every lookup’s hostname to the local resolver - prefer socks5h for
Tor.
Two honesty notes, stated plainly:
- The startup check proves the proxy at launch, not the whole run’s path. It resolves the environment with the stack’s own precedence rules and announces the proxy in use, but it is not a continuous guarantee: before a sensitive run, verify your exit IP.
- Anonymous runs through Tor are practically unusable: GitHub’s anonymous quota (about 60 requests per hour) is shared by every user of that exit node, so a shared exit is spent before your first scan finishes. With a token, the quota is per token (5,000 requests per hour), but expect more frequent secondary-limit pauses (the tool parks a token until its window passes and rotates to the next one); the park semantics are described in Rate limits.
GitHub applies the same rate limits and the same abuse detection whatever the request’s origin; this guide documents what the stack honors, not techniques to avoid either.
Rate limits
GitHub rate limits come in two flavors, and the client reacts to each one differently.
Primary limit (quota)
A 403 whose x-ratelimit-remaining header is 0 means a quota is
spent, and which quota decides the reaction:
- on the core endpoints, the token’s hourly quota is spent: the token is
parked until the instant the
x-ratelimit-resetheader names (capped at one hour so a hostile header cannot park it indefinitely), then returns automatically, and the request is retried with the next available token. A spent quota is a pause, not a death: a long parallel scan gets its token back at the reset. When the header is absent (a middlebox stripped it), there is no known window to wait for: the token is retired (removed from the allocation pool for the rest of the run) instead of being retried on a guessed window. A retired token never comes back; - on the
/search/endpoints (or any response carryingX-RateLimit-Resource: search), the spent quota is the separate per-minute search quota: the token’s hourly core quota is untouched, so the token is only parked for the short search window. Retiring a healthy token over a hot search minute would drain multi-token pools one token per minute.
When a run stops because every token is unusable, the error names the
cause: an hourly allowance drained pool stops with “every token’s allowance
is used up” (RateLimitExhausted), while a pool parked by secondary
windows stops with the dedicated paused message (“wait a few minutes”,
RateLimitPaused).
A 403 with x-ratelimit-remaining above 0 is a genuine refusal (for
example a private or missing resource) and is not treated as rate
limiting, unless it carries a Retry-After header, the secondary-limit
body, or a primary-limit body whose quota header a middlebox stripped
(the body text “API rate limit exceeded” is treated as transient too,
since the missing header makes the two indistinguishable). Any of these
three park the token instead (see below).
Secondary limit (abuse detection)
A 429 is never treated as a spent quota, because “too many requests” is a transient condition, so the token is parked, never retired, even when
the response carries x-ratelimit-remaining: 0. The same holds for a
403 carrying a Retry-After header or the documented secondary-limit
body without an exhausted core quota: the token is taken out of the
allocation pool until its window passes (Retry-After honored as-is,
capped at one hour so a hostile header cannot park a token indefinitely),
then returns automatically, and the request rotates immediately to another
live token when one exists. Parked tokens can still serve other workers
through the shared pool. A window that has already elapsed
(Retry-After: 0) is retried immediately at no cost. An HTTP 401
(revoked or invalid token) retires the token like a spent quota.
When a request fails on a token and another token is available, the request is transparently retried on the next token; the caller sees a failure only when no token can serve the request.
Caps and limits
| Limit | Value | Effect |
|---|---|---|
| Events timeline | 300 events | GitHub caps profile events; the listing stops there honestly |
| Repository commits | full history | the default-branch walk ends when GitHub’s listing ends (a short page); nothing is truncated locally |
| Search results | 1000 | GitHub’s documented search ceiling; reaching it ends the listing with a warning |
| Reverse-email candidates | full listing | every candidate repository the search serves is verified, each over its full default-branch history (until a commit-author match ends the lookup early) |
| Name pairs x domains | 64 x 64 | variant-generation inputs are truncated before generation |
| Generated variants | 1000 | global cap on generated candidate addresses |
| Custom patterns | 64 | --variant-pattern accepts at most 64 patterns |
| Bulk addresses file | 64 MiB | a bulk addresses file larger than 64 MiB is refused before reading |
| URL length | 2048 bytes | a URL longer than 2048 bytes is rejected before any request |
Search
search runs q=<query> against https://api.github.com/search/repositories.
--language appends the language: qualifier, and nine further qualifiers
compose with the query: --stars, --forks and --size take count ranges
(>100, 10..50), --created and --pushed take date ranges
(>2020-01-01, 2019-01-01..2020-01-01), and --license, --topic,
--user and --org take plain values. Every provided qualifier is
validated client-side before any request: a malformed range or an
impossible date fails fast with an explicit error instead of surfacing as
an empty result set from the API. --limit bounds how many repository
results are scanned.
A 422 on the first page means the API rejected the query itself (an
over-long or invalid q, or an inaccessible --user/--org login): the
run fails explicitly instead of degrading into an empty report. A 422
past the first page is GitHub’s documented window boundary: the scan ends
with an explicit warning that the collected repositories were discarded,
rather than reporting a truncated set as complete.