Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Optional MX Lookup Cache for Enhanced Bulk Email Verification #143

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package emailverifier

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

var verifier = NewVerifier().EnableSMTPCheck()
var verifier = NewVerifier().EnableSMTPCheck().EnableMXCache(1 * time.Hour)

func TestIsFreeDomain_True(t *testing.T) {
domain := "gmail.com"
Expand Down
17 changes: 15 additions & 2 deletions mx.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
package emailverifier

import "net"
import (
"net"
)

// Mx is detail about the Mx host
type Mx struct {
HasMXRecord bool // whether has 1 or more MX record
Records []*net.MX // represent DNS MX records
}

type GetMXFunc func(domain string) ([]*net.MX, error)

// CheckMX will return the DNS MX records for the given domain name sorted by preference.
func (v *Verifier) CheckMX(domain string) (*Mx, error) {
domain = domainToASCII(domain)
mx, err := net.LookupMX(domain)

var mx []*net.MX
var err error

lookup := net.LookupMX
if v.mxCacheEnabled {
lookup = v.mxCache.Get
}
mx, err = lookup(domain)

if err != nil && len(mx) == 0 {
return nil, err
}
Expand Down
80 changes: 80 additions & 0 deletions mx_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package emailverifier

import (
"net"
"sync"
"time"
)

// MXCache represents a thread-safe cache for MX records
type MXCache struct {
sync.RWMutex
records map[string]cacheEntry
ttl time.Duration
}

type cacheEntry struct {
mxRecords []*net.MX
expiry time.Time
}

// NewMXCache creates and initializes a new MX cache and starts the cleanup goroutine
func NewMXCache(ttl time.Duration) *MXCache {
cache := &MXCache{
records: make(map[string]cacheEntry),
ttl: ttl,
}

// Start the cleanup process with a 15-minute interval
cache.StartCleanup(15 * time.Minute)

return cache
}

// Get retrieves MX records for a domain from cache or performs a lookup
func (c *MXCache) Get(domain string) ([]*net.MX, error) {
asciiDomain := domainToASCII(domain)

// Check cache first
c.RLock()
if entry, exists := c.records[asciiDomain]; exists {
if time.Now().Before(entry.expiry) {
c.RUnlock()
return entry.mxRecords, nil
}
}
c.RUnlock()

// Perform actual lookup if not in cache or expired
mxRecords, err := net.LookupMX(asciiDomain)
if err != nil {
return nil, err
}

// Update cache
c.Lock()
c.records[asciiDomain] = cacheEntry{
mxRecords: mxRecords,
expiry: time.Now().Add(c.ttl),
}
c.Unlock()

return mxRecords, nil
}

// StartCleanup initiates periodic cleanup of expired cache entries
func (c *MXCache) StartCleanup(interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
c.Lock()
now := time.Now()
for domain, entry := range c.records {
if now.After(entry.expiry) {
delete(c.records, domain)
}
}
c.Unlock()
}
}()
}
23 changes: 23 additions & 0 deletions mx_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package emailverifier

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestCheckMxWithCacheOK(t *testing.T) {
domain := "github.com"

mx, err := verifier.CheckMX(domain)
assert.NoError(t, err)
assert.True(t, mx.HasMXRecord)
}

func TestCheckNoMxWithCacheOK(t *testing.T) {
domain := "githubexists.com"

mx, err := verifier.CheckMX(domain)
assert.Nil(t, mx)
assert.Error(t, err, ErrNoSuchHost)
}
16 changes: 13 additions & 3 deletions smtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ func (v *Verifier) CheckSMTP(domain, username string) (*SMTP, error) {
email := fmt.Sprintf("%s@%s", username, domain)

// Dial any SMTP server that will accept a connection
client, mx, err := newSMTPClient(domain, v.proxyURI, v.connectTimeout, v.operationTimeout)
var getMxFunc GetMXFunc
if v.mxCacheEnabled {
getMxFunc = v.mxCache.Get
} else {
getMxFunc = net.LookupMX
}
client, mx, err := newSMTPClient(domain, v.proxyURI, v.connectTimeout, v.operationTimeout, getMxFunc)
if err != nil {
return &ret, ParseSMTPError(err)
}
Expand Down Expand Up @@ -113,9 +119,13 @@ func (v *Verifier) CheckSMTP(domain, username string) (*SMTP, error) {
}

// newSMTPClient generates a new available SMTP client
func newSMTPClient(domain, proxyURI string, connectTimeout, operationTimeout time.Duration) (*smtp.Client, *net.MX, error) {
func newSMTPClient(domain, proxyURI string, connectTimeout, operationTimeout time.Duration, getMx GetMXFunc) (*smtp.Client, *net.MX, error) {
domain = domainToASCII(domain)
mxRecords, err := net.LookupMX(domain)

var mxRecords []*net.MX
var err error
mxRecords, err = getMx(domain)

if err != nil {
return nil, nil, err
}
Expand Down
10 changes: 7 additions & 3 deletions smtp_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package emailverifier

import (
"net"
"strings"
"syscall"
"testing"
Expand Down Expand Up @@ -196,7 +197,8 @@ func TestCheckSMTPOK_HostNotExists(t *testing.T) {
func TestNewSMTPClientOK(t *testing.T) {
domain := "gmail.com"
timeout := 5 * time.Second
ret, _, err := newSMTPClient(domain, "", timeout, timeout)
var getMx GetMXFunc = net.LookupMX
ret, _, err := newSMTPClient(domain, "", timeout, timeout, getMx)
assert.NotNil(t, ret)
assert.Nil(t, err)
}
Expand All @@ -205,15 +207,17 @@ func TestNewSMTPClientFailed_WithInvalidProxy(t *testing.T) {
domain := "gmail.com"
proxyURI := "socks5://user:[email protected]:1080?timeout=5s"
timeout := 5 * time.Second
ret, _, err := newSMTPClient(domain, proxyURI, timeout, timeout)
var getMx GetMXFunc = net.LookupMX
ret, _, err := newSMTPClient(domain, proxyURI, timeout, timeout, getMx)
assert.Nil(t, ret)
assert.Error(t, err, syscall.ECONNREFUSED)
}

func TestNewSMTPClientFailed(t *testing.T) {
domain := "zzzz171777.com"
timeout := 5 * time.Second
ret, _, err := newSMTPClient(domain, "", timeout, timeout)
var getMx GetMXFunc = net.LookupMX
ret, _, err := newSMTPClient(domain, "", timeout, timeout, getMx)
assert.Nil(t, ret)
assert.Error(t, err)
}
Expand Down
15 changes: 15 additions & 0 deletions verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type Verifier struct {
catchAllCheckEnabled bool // SMTP catchAll check enabled or disabled (enabled by default)
domainSuggestEnabled bool // whether suggest a most similar correct domain or not (disabled by default)
gravatarCheckEnabled bool // gravatar check enabled or disabled (disabled by default)
mxCacheEnabled bool // MX cache enabled or disabled (disabled by default)
mxCache *MXCache // MX cache
fromEmail string // name to use in the `EHLO:` SMTP command, defaults to "[email protected]"
helloName string // email to use in the `MAIL FROM:` SMTP command. defaults to `localhost`
schedule *schedule // schedule represents a job schedule
Expand Down Expand Up @@ -207,6 +209,19 @@ func (v *Verifier) DisableAutoUpdateDisposable() *Verifier {

}

// EnableMXCache enables cache for MX records
func (v *Verifier) EnableMXCache(ttl time.Duration) *Verifier {
v.mxCache = NewMXCache(ttl)
v.mxCacheEnabled = true
return v
}

// DisableMXCache disables cache for MX records
func (v *Verifier) DisableMXCache() *Verifier {
v.mxCacheEnabled = false
return v
}

// FromEmail sets the emails to use in the `MAIL FROM:` smtp command
func (v *Verifier) FromEmail(email string) *Verifier {
v.fromEmail = email
Expand Down