Documentation
¶
Overview ¶
Package email provides email address validation and normalization for authcore.
Validation follows RFC 5321 and RFC 5322 rules:
- Total length 1–254 characters
- Exactly one @ separating a non-empty local part and domain
- Local part ≤ 64 characters
- Domain contains at least one dot; no leading, trailing, or consecutive dots
- Each domain label is 1–63 characters
Internationalised domain names (IDN) are supported: a Unicode domain such as "münchen.de" is converted to its ASCII punycode form ("xn--mnchen-3ya.de") during normalisation, matching what DNS actually resolves. Store the canonical ASCII form in your database so lookups are deterministic.
The single entry point is Email.ValidateAndNormalize — it normalizes (lowercase + trim + punycode) and validates in one step, returning the canonical form. Always store and query emails using this canonical form:
emailMod, _ := email.New(auth)
// Registration
normalized, err := emailMod.ValidateAndNormalize(req.Email)
if err != nil {
c.JSON(400, map[string]string{"error": errors.Unwrap(err).Error()})
return
}
db.StoreUser(normalized, ...)
// Login lookup — same call, same canonical form, consistent results
normalized, err = emailMod.ValidateAndNormalize(req.Email)
if err != nil { ... }
user := db.FindByEmail(normalized)
Domain MX verification ¶
VerifyDomain performs an optional DNS MX lookup to confirm the domain can receive email. Results are cached per domain using the DNS TTL (capped at DefaultCacheTTL) to avoid repeated lookups for the same domain. This check is network I/O — always call it after ValidateAndNormalize and handle ErrDomainUnresolvable as a soft failure:
err = emailMod.VerifyDomain(ctx, normalized)
if errors.Is(err, email.ErrDomainNoMX) {
c.JSON(400, map[string]string{"error": "email domain cannot receive messages"})
return
}
if errors.Is(err, email.ErrDomainUnresolvable) {
log.Warn("DNS check unavailable: %v", err) // do not block the user
}
Index ¶
Examples ¶
Constants ¶
const DefaultCacheTTL = 5 * time.Minute
DefaultCacheTTL is the maximum duration a domain MX lookup result is cached. Tune this value if your workload has strict freshness requirements.
Variables ¶
var ErrDomainNoMX = errors.New("email: domain has no MX records")
ErrDomainNoMX is CLIENT-SAFE: the domain exists but has no MX records, meaning it cannot receive email. Safe to return as a 400 response:
if errors.Is(err, email.ErrDomainNoMX) {
c.JSON(400, map[string]string{"error": "email domain cannot receive messages"})
return
}
var ErrDomainUnresolvable = errors.New("email: domain could not be resolved")
ErrDomainUnresolvable is INTERNAL: the DNS lookup failed due to a network error, timeout, or resolver unavailability. Do NOT block the user on this — log the error and let the request proceed:
if errors.Is(err, email.ErrDomainUnresolvable) {
log.Warn("DNS check failed, skipping: %v", err)
// continue — don't block the user
}
var ErrInvalidEmail = errors.New("email: invalid address")
ErrInvalidEmail signals that an address failed RFC 5321/5322 validation.
CLIENT-SAFE: the wrapped reason describes exactly which rule failed and is suitable for returning in a 400 response:
normalized, err := emailMod.ValidateAndNormalize(req.Email)
if err != nil {
c.JSON(400, map[string]string{"error": errors.Unwrap(err).Error()})
return
}
Use errors.Is to check for this in calling code.
Functions ¶
This section is empty.
Types ¶
type Email ¶
type Email struct {
// contains filtered or unexported fields
}
Email is the email validation and normalization module. Create one instance at startup via New and reuse it — it is safe for concurrent use after construction. It owns no background goroutine and needs no cleanup; like the other modules, you construct it and use it.
func New ¶
New creates an Email module using the provider's logger.
emailMod, err := email.New(auth)
if err != nil { ... }
Example ¶
package main
import (
"fmt"
"os"
"github.com/Glyndor/authcore"
"github.com/Glyndor/authcore/auth/email"
)
func main() {
dir, _ := os.MkdirTemp("", "authcore-example-")
defer func() { _ = os.RemoveAll(dir) }()
auth, _ := authcore.New(authcore.Config{EnableLogs: false, KeysDir: dir})
mod, err := email.New(auth)
if err != nil {
panic(err)
}
defer mod.Close()
fmt.Println(mod.Name())
}
Output: email
func (*Email) Close ¶
func (e *Email) Close()
Close is a no-op retained for backward compatibility.
The module no longer runs a background goroutine: the MX cache evicts expired entries lazily when it fills (see store), and reads always ignore expired entries. Nothing needs to be released, so calling Close is optional and always safe — including multiple times and from multiple goroutines.
func (*Email) ValidateAndNormalize ¶
ValidateAndNormalize is the single entry point for email validation. It lowercases, trims surrounding whitespace, and validates the address against RFC 5321 / RFC 5322 rules in one atomic step.
Always use this function — never normalize and validate separately. The returned string is the canonical form that must be stored and queried:
normalized, err := emailMod.ValidateAndNormalize(req.Email)
if err != nil {
// errors.Unwrap(err).Error() contains the specific rule that failed.
c.JSON(400, map[string]string{"error": errors.Unwrap(err).Error()})
return
}
db.StoreUser(normalized, ...) // always lowercase, trimmed, validated
Example ¶
package main
import (
"fmt"
"os"
"github.com/Glyndor/authcore"
"github.com/Glyndor/authcore/auth/email"
)
func main() {
dir, _ := os.MkdirTemp("", "authcore-example-")
defer func() { _ = os.RemoveAll(dir) }()
auth, _ := authcore.New(authcore.Config{EnableLogs: false, KeysDir: dir})
mod, _ := email.New(auth)
defer mod.Close()
got, err := mod.ValidateAndNormalize(" User@Example.COM ")
if err != nil {
fmt.Println("invalid:", err)
return
}
fmt.Println(got)
}
Output: user@example.com
func (*Email) VerifyDomain ¶
VerifyDomain performs a DNS MX lookup to confirm that the email's domain can receive messages. It is an optional, network-bound complement to ValidateAndNormalize — call it only after format validation succeeds.
Results are cached per domain for the duration of the DNS TTL, capped at DefaultCacheTTL, to avoid repeated lookups for the same domain.
The cache and the single-flight de-duplication bound repeated and concurrent lookups for the SAME domain, but each uncached distinct domain still costs one outbound DNS query — a flood of unique domains is not bounded here. Rate-limit the endpoint that calls VerifyDomain (registration) so an attacker cannot turn it into a DNS-amplification vector.
On success it returns nil. On failure it returns one of:
[ErrDomainNoMX] — domain exists but has no MX records (CLIENT-SAFE, return 400) [ErrDomainUnresolvable] — DNS lookup failed; treat as a soft failure and do not block the user
ctx controls the deadline of the DNS query. Use a short timeout (1–3 s) to avoid slowing down your registration endpoint:
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if err := emailMod.VerifyDomain(ctx, normalized); errors.Is(err, email.ErrDomainNoMX) {
c.JSON(400, map[string]string{"error": "email domain cannot receive messages"})
return
}
Example ¶
package main
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/Glyndor/authcore"
"github.com/Glyndor/authcore/auth/email"
)
func main() {
dir, _ := os.MkdirTemp("", "authcore-example-")
defer func() { _ = os.RemoveAll(dir) }()
auth, _ := authcore.New(authcore.Config{EnableLogs: false, KeysDir: dir})
mod, _ := email.New(auth)
defer mod.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := mod.VerifyDomain(ctx, "user@example.com")
switch {
case errors.Is(err, email.ErrDomainNoMX):
fmt.Println("no MX records — block registration")
case errors.Is(err, email.ErrDomainUnresolvable):
fmt.Println("DNS soft failure — log and proceed")
case err != nil:
fmt.Println("error:", err)
default:
fmt.Println("MX OK")
}
}
Output: