Files
Jason MaandGitHub f03867b8d5 feat(build_metadata): keep real providers out of the disposable list (#209)
The upstream disposable list classifies a handful of real mailbox providers as
throwaway services, and that misfiling costs two answers rather than one.
free.txt is the free candidates minus the disposable list, so a domain wrongly
listed there is not merely reported disposable -- it is also dropped from free,
and the library ends up answering "neither" about a real provider.

disposable_allowlist.txt is subtracted when the lists are generated and applied
again after every EnableAutoUpdateDisposable() refresh. Both halves are needed:
the generated map is what a caller gets who never enables auto-update, and
without the runtime half the first refresh reinstates every entry.

The maintenance surface is bounded, which is what makes this worth having
rather than an open-ended list to police. Of the 5142 free candidates, 612 are
subtracted as disposable, and 556 of those are corroborated by at least one
blocklist other than the one we use. That leaves 56 claims resting on a single
source -- reviewable in one sitting, and 41 of them are plainly right: eleven
free-subdomain hosts under .cu.cc, .uu.gl, .ye.vc and .usa.cc, two
*.otherinbox.com, tempmail.net, junkmail.com, the typosquats 74gmail.com and
yaho.com, test.com, and a run of domains with no MX at all.

Six entries have evidence worth acting on:

  4x4man.com  alphafrau.de  mail2world.com  qmail.com

    Catalogued as free mail services by disposable/disposable's own
    mailservices.json. That file is wired into nothing: the only switch that
    reads it, --add-free-mailservices, is passed from cli.py into options the
    generator never consults, and the data repo's cron does not set it anyway.
    Exclusions there come from whitelist.txt and greylist.txt, and none of
    these is in either.

  hush.com  lavabit.com

    Long-running providers on their own mail infrastructure, neither offering
    throwaway addresses.

Left out deliberately: email.org, 8848.net, unomail.com, uomail.com,
vjmail.com, webcity.ca, whale-mail.com, yyhmail.com, moose-mail.com,
movemail.com, snail-mail.net, mail15.com, latinmail.com, za.com and sify.com.
Some are probably misfiled too, but a live MX is the only evidence for them,
and most disposable domains have one. sify.com is the most likely of these to
be wrong -- a real Indian portal -- but two independent blocklists name it, so
it does not belong in a list whose entries are supposed to be checkable.

  disposable.txt   75264 -> 75258
  free.txt          4529 ->  4535

All six move from Free=false/Disposable=true to Free=true/Disposable=false, and
the fifteen left out stay disposable.

update.sh fails if an allowlist entry is no longer in the upstream list. An
entry that upstream has since fixed, or that was never needed, is invisible
otherwise, and a list nobody prunes is how this kind of file rots.

The generator now skips blank and comment lines. It quoted every line it read,
so a hand-maintained list could not explain its own entries -- every "#" line
would have become a map key. No effect on the three existing lists, which
update.sh writes without either.

Tests, each confirmed to fail when its mechanism is removed:

  TestAllowedDomainsAreNotDisposable          the build-time subtraction ran
  TestUpdateDisposableDomainsKeepsAllowlistOut a refresh does not reinstate them
  TestAddDisposableDomainsOutranksAllowlist    an explicit caller still wins
  TestGeneratedMetadataMatchesSources          now covers the allowlist too

The staleness guard was checked the same way, by adding a domain upstream does
not list: the script refuses to publish and exits 1 before writing anything.

The restore-the-package-set cleanup added to TestUpdateDisposableDomainsOK in
an earlier commit is now shared as restoreDisposableDomains, since three tests
replace that set.
2026-09-09 18:29:58 +08:00

201 lines
7.5 KiB
Go

package emailverifier
import (
"bufio"
"os"
"regexp"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var verifier = NewVerifier().EnableSMTPCheck()
// The metadata_*.go files are generated from the .txt lists under
// cmd/build_metadata, and nothing kept the two in step. They drifted: cto,
// ctos, cfo and cfos were added to role.txt in December 2025 and
// metadata_role.go was never regenerated, so IsRoleAccount("cto") returned
// false for the nine months that followed. This fails if a list is edited
// without regenerating.
func TestGeneratedMetadataMatchesSources(t *testing.T) {
cases := []struct {
name string
source string
generated map[string]bool
}{
{"disposable", "cmd/build_metadata/disposable.txt", disposableDomains},
{"free", "cmd/build_metadata/free.txt", freeDomains},
{"role", "cmd/build_metadata/role.txt", roleAccounts},
{"disposable allowlist", "cmd/build_metadata/disposable_allowlist.txt", allowedDisposableDomains},
}
for _, c := range cases {
test := c
t.Run(test.name, func(tt *testing.T) {
file, err := os.Open(test.source)
require.NoError(tt, err)
defer func() { assert.NoError(tt, file.Close()) }()
// The generator skips duplicates, so compare against the set of
// distinct entries rather than the line count.
entries := make(map[string]bool)
var missing []string
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
entry := strings.TrimSpace(scanner.Text())
// Skipped the same way the generator skips them, so a
// hand-maintained list can carry its own reasoning.
if entry == "" || strings.HasPrefix(entry, "#") {
continue
}
entries[entry] = true
if !test.generated[entry] && len(missing) < 10 {
missing = append(missing, entry)
}
}
require.NoError(tt, scanner.Err())
require.NotEmpty(tt, entries, "source list is empty")
assert.Emptyf(tt, missing, "%s has entries missing from the generated map; run `cd cmd/build_metadata && go run main.go`", test.source)
// Compare counts rather than the maps themselves; asserting on the
// containers dumps six figures of entries into the failure output.
sourceCount, generatedCount := len(entries), len(test.generated)
assert.Equalf(tt, sourceCount, generatedCount, "%s holds %d distinct entries but the generated map holds %d", test.source, sourceCount, generatedCount)
})
}
}
// Every free source is a signup blocklist rather than a directory of
// providers -- HubSpot publishes theirs under Marketing/Lead-Capture -- so
// they carry throwaway domains next to the real ones and update.sh subtracts
// the disposable list from them. That subtraction is the only thing keeping
// the two apart, and getting it wrong is not loud: consuming a free list that
// had merged two disposable blocklists into itself put ~5k throwaway domains,
// whole families such as aws-mail-free-<n>.dynv6.net, into free.txt, where
// IsFreeDomain called them free and SuggestDomain offered them as
// corrections. Assert the property the subtraction provides.
// EnableAutoUpdateDisposable refetches the list at disposableDataURL and, via
// updateDisposableDomains, deletes every baked-in domain the fetched list
// omits. So the list cmd/build_metadata bakes in has to be the list this
// constant points at. It was not: the script built from tompec while this
// pointed at disposable/, lists sharing only 37585 of tompec's 133602
// entries, so turning auto-update on dropped 96017 domains and added 37679.
func TestUpdateScriptFetchesTheDisposableDataURL(t *testing.T) {
script, err := os.ReadFile("cmd/build_metadata/update.sh")
require.NoError(t, err)
assert.Contains(t, string(script), disposableDataURL,
"cmd/build_metadata/update.sh must bake in the same disposable list that disposableDataURL refreshes at runtime")
}
func TestFreeAndDisposableDomainsAreDisjoint(t *testing.T) {
var overlap []string
for domain := range freeDomains {
if disposableDomains[domain] {
if len(overlap) < 10 {
overlap = append(overlap, domain)
}
}
}
assert.Empty(t, overlap, "domains classified as both free and disposable; check the subtraction in cmd/build_metadata/update.sh")
}
// A key that is not a hostname can never be returned by a lookup, so it is
// dead weight that no test notices, and both maps shipped some. free.txt
// carried a U+00A0 no-break space on the end of atlanticbb.net for years:
// one upstream entry has one attached, and LC_ALL=C [[:space:]] does not
// match it, so every cleanup in update.sh walked past it and
// IsFreeDomain("atlanticbb.net") returned false. The disposable list carried
// twelve IDNs spelled in Unicode rather than punycode, dead for a related
// reason -- IsDisposable converts its argument with domainToASCII before the
// lookup, so nothing it was given could ever match them.
func TestGeneratedDomainsAreWellFormedHostnames(t *testing.T) {
wellFormed := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
for name, generated := range map[string]map[string]bool{
"free": freeDomains,
"disposable": disposableDomains,
} {
var malformed []string
for domain := range generated {
if !wellFormed.MatchString(domain) {
if len(malformed) < 10 {
malformed = append(malformed, strconv.Quote(domain))
}
}
}
assert.Emptyf(t, malformed, "%s domains that no lookup can match; check the normalisation in cmd/build_metadata/update.sh", name)
}
}
// disposable_allowlist.txt exists because the upstream disposable list
// classifies a handful of real providers as throwaway services. update.sh
// subtracts it, so nothing in it should reach the generated map -- and the
// subtraction matters twice, since free.txt is the free candidates minus the
// disposable list: a domain wrongly listed there is not merely reported
// disposable, it is also dropped from free, leaving the library answering
// "neither" about a real mailbox provider.
func TestAllowedDomainsAreNotDisposable(t *testing.T) {
require.NotEmpty(t, allowedDisposableDomains, "allowlist is empty; the generated map is stale")
var listed []string
for domain := range allowedDisposableDomains {
if disposableDomains[domain] {
listed = append(listed, domain)
}
}
assert.Empty(t, listed, "allowlisted domains still in the generated disposable map; check the subtraction in cmd/build_metadata/update.sh")
}
func TestIsFreeDomain_True(t *testing.T) {
domain := "gmail.com"
isFreeDomain := verifier.IsFreeDomain(domain)
assert.True(t, isFreeDomain)
}
func TestCheckNotFreeDomain_False(t *testing.T) {
domain := "github.com"
isFreeDomain := verifier.IsFreeDomain(domain)
assert.False(t, isFreeDomain)
}
func TestIsDisposableDomain_True(t *testing.T) {
// A long-lived service rather than one of the churning throwaway domains:
// the previous fixture, dbbd8.club, was only ever in the list this repo
// used to build from and vanished when the two were reconciled.
domain := "mailinator.com"
isDisposable := verifier.IsDisposable(domain)
assert.True(t, isDisposable)
}
func TestIsDisposableDomain_False(t *testing.T) {
domain := "gmail.com"
isDisposable := verifier.IsDisposable(domain)
assert.False(t, isDisposable)
}
func TestIsRoleAccount_True(t *testing.T) {
username := "administrator"
isRoleAccount := verifier.IsRoleAccount(username)
assert.True(t, isRoleAccount)
}
func TestIsRoleAccount_False(t *testing.T) {
username := "normal_user"
isRoleAccount := verifier.IsRoleAccount(username)
assert.False(t, isRoleAccount)
}