consider Return-Path in SPF checks
This commit is contained in:
parent
a6f52fe1ee
commit
952ff99fc7
19 changed files with 567 additions and 308 deletions
2
go.mod
2
go.mod
|
|
@ -27,7 +27,7 @@ require (
|
||||||
gitlab.com/etke.cc/go/mxidwc v1.0.0
|
gitlab.com/etke.cc/go/mxidwc v1.0.0
|
||||||
gitlab.com/etke.cc/go/psd v1.1.2
|
gitlab.com/etke.cc/go/psd v1.1.2
|
||||||
gitlab.com/etke.cc/go/secgen v1.2.0
|
gitlab.com/etke.cc/go/secgen v1.2.0
|
||||||
gitlab.com/etke.cc/go/validator v1.0.7
|
gitlab.com/etke.cc/go/validator/v2 v2.2.0
|
||||||
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54
|
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
|
||||||
maunium.net/go/mautrix v0.19.0
|
maunium.net/go/mautrix v0.19.0
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -116,8 +116,8 @@ gitlab.com/etke.cc/go/secgen v1.2.0 h1:qpV7rUn5Rs6eWxAmbGG/idPCOgsN4HggGmSZ+1R/L
|
||||||
gitlab.com/etke.cc/go/secgen v1.2.0/go.mod h1:v5L07AIXtNpC/miYiK0TMIn+ZKbiYrTRiXTw6qTL6pw=
|
gitlab.com/etke.cc/go/secgen v1.2.0/go.mod h1:v5L07AIXtNpC/miYiK0TMIn+ZKbiYrTRiXTw6qTL6pw=
|
||||||
gitlab.com/etke.cc/go/trysmtp v1.1.3 h1:e2EHond77onMaecqCg6mWumffTSEf+ycgj88nbeefDI=
|
gitlab.com/etke.cc/go/trysmtp v1.1.3 h1:e2EHond77onMaecqCg6mWumffTSEf+ycgj88nbeefDI=
|
||||||
gitlab.com/etke.cc/go/trysmtp v1.1.3/go.mod h1:lOO7tTdAE0a3ETV3wN3GJ7I1Tqewu7YTpPWaOmTteV0=
|
gitlab.com/etke.cc/go/trysmtp v1.1.3/go.mod h1:lOO7tTdAE0a3ETV3wN3GJ7I1Tqewu7YTpPWaOmTteV0=
|
||||||
gitlab.com/etke.cc/go/validator v1.0.7 h1:4BGDTa9x68vJhbyn7m8W2yX+2Nb5im9+JLRrgoLUlF4=
|
gitlab.com/etke.cc/go/validator/v2 v2.2.0 h1:XgB8XikOc3WzzFyimpZOt3p+kbHny98sGkXtp2tSwzQ=
|
||||||
gitlab.com/etke.cc/go/validator v1.0.7/go.mod h1:Id0SxRj0J3IPhiKlj0w1plxVLZfHlkwipn7HfRZsDts=
|
gitlab.com/etke.cc/go/validator/v2 v2.2.0/go.mod h1:NBY0v4ZqR1GQpuojf369EW2oS/4Ll1uIs9YTgijRbaY=
|
||||||
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54 h1:+ihVGkVBBWlRyugJ3dvmA1rwqC9jFXrUZSslkYFN0Gw=
|
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54 h1:+ihVGkVBBWlRyugJ3dvmA1rwqC9jFXrUZSslkYFN0Gw=
|
||||||
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54/go.mod h1:R/uxbOoajQVxm0qCbWYfox6B9uqnM5R0/KMJcLSr6zM=
|
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54/go.mod h1:R/uxbOoajQVxm0qCbWYfox6B9uqnM5R0/KMJcLSr6zM=
|
||||||
go.mau.fi/util v0.6.0 h1:W6SyB3Bm/GjenQ5iq8Z8WWdN85Gy2xS6L0wmnR7SVjg=
|
go.mau.fi/util v0.6.0 h1:W6SyB3Bm/GjenQ5iq8Z8WWdN85Gy2xS6L0wmnR7SVjg=
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,8 @@ package smtp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// validatorLoggerWrapper is a wrapper around zerolog.Logger to implement validator.Logger interface
|
|
||||||
type validatorLoggerWrapper struct {
|
|
||||||
log *zerolog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l validatorLoggerWrapper) Info(msg string, args ...any) {
|
|
||||||
l.log.Info().Msgf(msg, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l validatorLoggerWrapper) Error(msg string, args ...any) {
|
|
||||||
l.log.Error().Msgf(msg, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loggerWrapper is a wrapper around any logger to implement smtp.Logger interface
|
// loggerWrapper is a wrapper around any logger to implement smtp.Logger interface
|
||||||
type loggerWrapper struct {
|
type loggerWrapper struct {
|
||||||
log func(string, ...any)
|
log func(string, ...any)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import (
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
"github.com/jhillyerd/enmime"
|
"github.com/jhillyerd/enmime"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"gitlab.com/etke.cc/go/validator"
|
"gitlab.com/etke.cc/go/validator/v2"
|
||||||
"gitlab.com/etke.cc/postmoogle/email"
|
"gitlab.com/etke.cc/postmoogle/email"
|
||||||
"gitlab.com/etke.cc/postmoogle/utils"
|
"gitlab.com/etke.cc/postmoogle/utils"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
@ -171,7 +171,7 @@ func (s *session) incomingData(r io.Reader) error {
|
||||||
addr := s.getAddr(envelope)
|
addr := s.getAddr(envelope)
|
||||||
reader.Seek(0, io.SeekStart) //nolint:errcheck // becase we're sure that's ok
|
reader.Seek(0, io.SeekStart) //nolint:errcheck // becase we're sure that's ok
|
||||||
validations := s.bot.GetIFOptions(s.ctx, s.roomID)
|
validations := s.bot.GetIFOptions(s.ctx, s.roomID)
|
||||||
if !validateIncoming(s.from, s.tos[0], addr, s.log, validations) {
|
if !validateIncoming(s.from, envelope.GetHeader("Return-Path"), addr, s.log, validations) {
|
||||||
s.bot.BanAuth(s.ctx, addr)
|
s.bot.BanAuth(s.ctx, addr)
|
||||||
return ErrBanned
|
return ErrBanned
|
||||||
}
|
}
|
||||||
|
|
@ -288,7 +288,7 @@ func (s *session) getAddr(envelope *enmime.Envelope) net.Addr {
|
||||||
return realAddr
|
return realAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateIncoming(from, to string, senderAddr net.Addr, log *zerolog.Logger, options email.IncomingFilteringOptions) bool {
|
func validateIncoming(from, returnPath string, senderAddr net.Addr, log *zerolog.Logger, options email.IncomingFilteringOptions) bool {
|
||||||
var sender net.IP
|
var sender net.IP
|
||||||
switch netaddr := senderAddr.(type) {
|
switch netaddr := senderAddr.(type) {
|
||||||
case *net.TCPAddr:
|
case *net.TCPAddr:
|
||||||
|
|
@ -298,13 +298,18 @@ func validateIncoming(from, to string, senderAddr net.Addr, log *zerolog.Logger,
|
||||||
sender = net.ParseIP(host)
|
sender = net.ParseIP(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce := validator.Enforce{
|
vcfg := &validator.Config{
|
||||||
Email: true,
|
Log: log.Warn().Msgf,
|
||||||
MX: options.SpamcheckMX(),
|
Email: validator.Email{
|
||||||
SPF: options.SpamcheckSPF(),
|
Enforce: true,
|
||||||
SMTP: options.SpamcheckSMTP(),
|
Spamlist: options.Spamlist(),
|
||||||
|
MX: options.SpamcheckMX(),
|
||||||
|
SPF: options.SpamcheckSPF(),
|
||||||
|
SMTP: options.SpamcheckSMTP(),
|
||||||
|
From: from,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
v := validator.New(options.Spamlist(), enforce, to, &validatorLoggerWrapper{log: log})
|
v := validator.New(vcfg)
|
||||||
|
|
||||||
return v.Email(from, sender)
|
return v.Email(from, returnPath, sender)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
vendor/gitlab.com/etke.cc/go/validator/.gitlab-ci.yml
generated
vendored
11
vendor/gitlab.com/etke.cc/go/validator/.gitlab-ci.yml
generated
vendored
|
|
@ -1,11 +0,0 @@
|
||||||
lint:
|
|
||||||
image: registry.gitlab.com/etke.cc/base
|
|
||||||
script:
|
|
||||||
- golangci-lint run ./...
|
|
||||||
|
|
||||||
unit:
|
|
||||||
image: registry.gitlab.com/etke.cc/base
|
|
||||||
script:
|
|
||||||
- go test -coverprofile=cover.out ./...
|
|
||||||
- go tool cover -func=cover.out
|
|
||||||
- rm -f cover.out
|
|
||||||
142
vendor/gitlab.com/etke.cc/go/validator/emails.go
generated
vendored
142
vendor/gitlab.com/etke.cc/go/validator/emails.go
generated
vendored
|
|
@ -1,142 +0,0 @@
|
||||||
package validator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/mail"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"blitiri.com.ar/go/spf"
|
|
||||||
"gitlab.com/etke.cc/go/trysmtp"
|
|
||||||
"golang.org/x/net/context"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Email checks if email is valid
|
|
||||||
func (v *V) Email(email string, optionalSenderIP ...net.IP) bool {
|
|
||||||
|
|
||||||
// edge case: email may be optional
|
|
||||||
if email == "" {
|
|
||||||
return !v.enforce.Email
|
|
||||||
}
|
|
||||||
|
|
||||||
address, err := mail.ParseAddress(email)
|
|
||||||
if err != nil {
|
|
||||||
v.log.Info("email %s invalid, reason: %v", email, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
email = address.Address
|
|
||||||
return v.emailChecks(email, optionalSenderIP...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *V) emailChecks(email string, optionalSenderIP ...net.IP) bool {
|
|
||||||
maxChecks := 4
|
|
||||||
var senderIP net.IP
|
|
||||||
if len(optionalSenderIP) > 0 {
|
|
||||||
senderIP = optionalSenderIP[0]
|
|
||||||
}
|
|
||||||
errchan := make(chan error, maxChecks)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
go v.emailSpamlist(ctx, email, errchan)
|
|
||||||
go v.emailNoMX(ctx, email, errchan)
|
|
||||||
go v.emailNoSPF(ctx, email, senderIP, errchan)
|
|
||||||
go v.emailNoSMTP(ctx, email, errchan)
|
|
||||||
|
|
||||||
var checks int
|
|
||||||
for {
|
|
||||||
checks++
|
|
||||||
err := <-errchan
|
|
||||||
if err != nil {
|
|
||||||
v.log.Info("email %q is invalid, reason: %v", email, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if checks >= maxChecks {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *V) emailSpamlist(ctx context.Context, email string, errchan chan error) {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
emailb := []byte(email)
|
|
||||||
for _, spamregex := range v.spamlist {
|
|
||||||
if spamregex.Match(emailb) {
|
|
||||||
errchan <- fmt.Errorf("spamlist")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
errchan <- nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *V) emailNoMX(ctx context.Context, email string, errchan chan error) {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
if !v.enforce.MX {
|
|
||||||
errchan <- nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
at := strings.LastIndex(email, "@")
|
|
||||||
domain := email[at+1:]
|
|
||||||
if !v.MX(domain) {
|
|
||||||
v.log.Info("email %s domain %s invalid, reason: no MX", email, domain)
|
|
||||||
errchan <- fmt.Errorf("no MX")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
errchan <- nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *V) emailNoSMTP(ctx context.Context, email string, errchan chan error) {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
if !v.enforce.SMTP {
|
|
||||||
errchan <- nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := trysmtp.Connect(v.from, email)
|
|
||||||
if err != nil {
|
|
||||||
if strings.HasPrefix(err.Error(), "45") {
|
|
||||||
v.log.Info("email %s may be invalid, reason: SMTP check (%v)", email, err)
|
|
||||||
errchan <- nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
v.log.Info("email %s invalid, reason: SMTP check (%v)", email, err)
|
|
||||||
errchan <- fmt.Errorf("SMTP")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client.Close()
|
|
||||||
errchan <- nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *V) emailNoSPF(ctx context.Context, email string, senderIP net.IP, errchan chan error) {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
if !v.enforce.SPF {
|
|
||||||
errchan <- nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, _ := spf.CheckHostWithSender(senderIP, "", email, spf.WithTraceFunc(v.log.Info)) //nolint:errcheck // not a error
|
|
||||||
if result == spf.Fail {
|
|
||||||
errchan <- fmt.Errorf("SPF")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
errchan <- nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
vendor/gitlab.com/etke.cc/go/validator/lookups.go
generated
vendored
45
vendor/gitlab.com/etke.cc/go/validator/lookups.go
generated
vendored
|
|
@ -1,45 +0,0 @@
|
||||||
package validator
|
|
||||||
|
|
||||||
import "net"
|
|
||||||
|
|
||||||
// A checks if host has at least one A record
|
|
||||||
func (v *V) A(host string) bool {
|
|
||||||
if host == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ips, err := net.LookupIP(host)
|
|
||||||
if err != nil {
|
|
||||||
v.log.Error("cannot get A records of %s: %v", host, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(ips) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// CNAME checks if host has at least one CNAME record
|
|
||||||
func (v *V) CNAME(host string) bool {
|
|
||||||
if host == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cname, err := net.LookupCNAME(host)
|
|
||||||
if err != nil {
|
|
||||||
v.log.Error("cannot get CNAME records of %s: %v", host, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return cname != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// MX checks if host has at least one MX record
|
|
||||||
func (v *V) MX(host string) bool {
|
|
||||||
if host == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
mxs, err := net.LookupMX(host)
|
|
||||||
if err != nil {
|
|
||||||
v.log.Error("cannot get MX records of %s: %v", host, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(mxs) > 0
|
|
||||||
}
|
|
||||||
11
vendor/gitlab.com/etke.cc/go/validator/v2/.gitlab-ci.yml
generated
vendored
Normal file
11
vendor/gitlab.com/etke.cc/go/validator/v2/.gitlab-ci.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
lint:
|
||||||
|
stage: test
|
||||||
|
image: registry.gitlab.com/etke.cc/base/build
|
||||||
|
script:
|
||||||
|
- just lint
|
||||||
|
|
||||||
|
unit:
|
||||||
|
stage: test
|
||||||
|
image: registry.gitlab.com/etke.cc/base/build
|
||||||
|
script:
|
||||||
|
- just test
|
||||||
142
vendor/gitlab.com/etke.cc/go/validator/v2/.golangci.yml
generated
vendored
Normal file
142
vendor/gitlab.com/etke.cc/go/validator/v2/.golangci.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
run:
|
||||||
|
concurrency: 4
|
||||||
|
timeout: 30m
|
||||||
|
issues-exit-code: 1
|
||||||
|
tests: true
|
||||||
|
build-tags: []
|
||||||
|
skip-dirs-use-default: true
|
||||||
|
skip-files: []
|
||||||
|
modules-download-mode: readonly
|
||||||
|
|
||||||
|
output:
|
||||||
|
formats:
|
||||||
|
- format: colored-line-number
|
||||||
|
print-issued-lines: true
|
||||||
|
print-linter-name: true
|
||||||
|
sort-results: true
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
decorder:
|
||||||
|
dec-order:
|
||||||
|
- const
|
||||||
|
- var
|
||||||
|
- type
|
||||||
|
- func
|
||||||
|
dogsled:
|
||||||
|
max-blank-identifiers: 3
|
||||||
|
errcheck:
|
||||||
|
check-type-assertions: true
|
||||||
|
check-blank: true
|
||||||
|
errchkjson:
|
||||||
|
report-no-exported: true
|
||||||
|
exhaustive:
|
||||||
|
check:
|
||||||
|
- switch
|
||||||
|
- map
|
||||||
|
default-signifies-exhaustive: true
|
||||||
|
gocognit:
|
||||||
|
min-complexity: 15
|
||||||
|
nestif:
|
||||||
|
min-complexity: 5
|
||||||
|
gocritic:
|
||||||
|
enabled-tags:
|
||||||
|
- diagnostic
|
||||||
|
- style
|
||||||
|
- performance
|
||||||
|
gofmt:
|
||||||
|
simplify: true
|
||||||
|
rewrite-rules:
|
||||||
|
- pattern: 'interface{}'
|
||||||
|
replacement: 'any'
|
||||||
|
- pattern: 'a[b:len(a)]'
|
||||||
|
replacement: 'a[b:]'
|
||||||
|
gofumpt:
|
||||||
|
extra-rules: true
|
||||||
|
grouper:
|
||||||
|
const-require-single-const: true
|
||||||
|
import-require-single-import: true
|
||||||
|
var-require-single-var: true
|
||||||
|
misspell:
|
||||||
|
locale: US
|
||||||
|
usestdlibvars:
|
||||||
|
time-month: true
|
||||||
|
time-layout: true
|
||||||
|
crypto-hash: true
|
||||||
|
default-rpc-path: true
|
||||||
|
sql-isolation-level: true
|
||||||
|
tls-signature-scheme: true
|
||||||
|
constant-kind: true
|
||||||
|
unparam:
|
||||||
|
check-exported: true
|
||||||
|
linters:
|
||||||
|
disable-all: false
|
||||||
|
enable:
|
||||||
|
- asasalint
|
||||||
|
- asciicheck
|
||||||
|
- bidichk
|
||||||
|
- bodyclose
|
||||||
|
- containedctx
|
||||||
|
- decorder
|
||||||
|
- dogsled
|
||||||
|
- dupl
|
||||||
|
- dupword
|
||||||
|
- durationcheck
|
||||||
|
- errcheck
|
||||||
|
- errchkjson
|
||||||
|
- errname
|
||||||
|
- errorlint
|
||||||
|
- exhaustive
|
||||||
|
- exportloopref
|
||||||
|
- forcetypeassert
|
||||||
|
- gocognit
|
||||||
|
- gocritic
|
||||||
|
- gocyclo
|
||||||
|
- gofmt
|
||||||
|
- gofumpt
|
||||||
|
- goimports
|
||||||
|
- gosec
|
||||||
|
- gosimple
|
||||||
|
- gosmopolitan
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- makezero
|
||||||
|
- mirror
|
||||||
|
- misspell
|
||||||
|
- nestif
|
||||||
|
- nolintlint
|
||||||
|
- prealloc
|
||||||
|
- predeclared
|
||||||
|
- revive
|
||||||
|
- sqlclosecheck
|
||||||
|
- staticcheck
|
||||||
|
- unconvert
|
||||||
|
- unparam
|
||||||
|
- unused
|
||||||
|
- usestdlibvars
|
||||||
|
- wastedassign
|
||||||
|
fast: false
|
||||||
|
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-dirs:
|
||||||
|
- mocks
|
||||||
|
exclude-rules:
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- gocyclo
|
||||||
|
- gocognit
|
||||||
|
- errcheck
|
||||||
|
- dupl
|
||||||
|
- gosec
|
||||||
|
- linters:
|
||||||
|
- staticcheck
|
||||||
|
text: "SA9003:"
|
||||||
|
- linters:
|
||||||
|
- lll
|
||||||
|
source: "^//go:generate "
|
||||||
|
- linters:
|
||||||
|
- revive
|
||||||
|
text: "returns unexported type"
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
new: false
|
||||||
35
vendor/gitlab.com/etke.cc/go/validator/v2/config.go
generated
vendored
Normal file
35
vendor/gitlab.com/etke.cc/go/validator/v2/config.go
generated
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package validator
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
|
// Config of the validator
|
||||||
|
type Config struct {
|
||||||
|
Email Email // Email config
|
||||||
|
Domain Domain // Domain config
|
||||||
|
Log func(msg string, args ...any) // Log func
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email checks configuration
|
||||||
|
type Email struct {
|
||||||
|
// Enforce enforces email check and rejects empty emails
|
||||||
|
Enforce bool
|
||||||
|
// SMTP enforces SMTP check (email actually exists on mail server) and rejects non-existing emails
|
||||||
|
SMTP bool
|
||||||
|
// SPF enforces SPF record check (sender allowed to use that email and send emails) and rejects unathorized emails
|
||||||
|
SPF bool
|
||||||
|
// MX enforces MX records check on email's mail server
|
||||||
|
MX bool
|
||||||
|
// From is a valid email address that will be used for SMTP checks
|
||||||
|
From string
|
||||||
|
// Spamlist is a list of spam emails with wildcards
|
||||||
|
Spamlist []string
|
||||||
|
spamlist []*regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain checks configuration
|
||||||
|
type Domain struct {
|
||||||
|
// Enforce enforces domain check and rejects empty domains
|
||||||
|
Enforce bool
|
||||||
|
// PrivateSuffixes considers subdomains with the following suffixes as domains
|
||||||
|
PrivateSuffixes []string
|
||||||
|
}
|
||||||
|
|
@ -9,12 +9,11 @@ import (
|
||||||
|
|
||||||
// based on W3C email regex, ref: https://www.w3.org/TR/2016/REC-html51-20161101/sec-forms.html#email-state-typeemail
|
// based on W3C email regex, ref: https://www.w3.org/TR/2016/REC-html51-20161101/sec-forms.html#email-state-typeemail
|
||||||
var domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`)
|
var domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`)
|
||||||
var privateSuffix = []string{".etke.host", ".onmatrix.chat"}
|
|
||||||
|
|
||||||
// Domain checks if domain is valid
|
// Domain checks if domain is valid
|
||||||
func (v *V) Domain(domain string) bool {
|
func (v *V) Domain(domain string) bool {
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
return !v.enforce.Domain
|
return !v.cfg.Domain.Enforce
|
||||||
}
|
}
|
||||||
|
|
||||||
if !v.DomainString(domain) {
|
if !v.DomainString(domain) {
|
||||||
|
|
@ -27,12 +26,12 @@ func (v *V) Domain(domain string) bool {
|
||||||
// DomainString checks if domain string / value is valid using string checks like length and regexp
|
// DomainString checks if domain string / value is valid using string checks like length and regexp
|
||||||
func (v *V) DomainString(domain string) bool {
|
func (v *V) DomainString(domain string) bool {
|
||||||
if len(domain) < 4 || len(domain) > 77 {
|
if len(domain) < 4 || len(domain) > 77 {
|
||||||
v.log.Info("domain %s invalid, reason: length", domain)
|
v.cfg.Log("domain %s invalid, reason: length", domain)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if !domainRegex.MatchString(domain) {
|
if !domainRegex.MatchString(domain) {
|
||||||
v.log.Info("domain %s invalid, reason: regexp", domain)
|
v.cfg.Log("domain %s invalid, reason: regexp", domain)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +60,7 @@ func (v *V) GetBase(domain string) string {
|
||||||
|
|
||||||
// hasSuffix checks if domain has a suffix from public suffix list or from predefined suffix list
|
// hasSuffix checks if domain has a suffix from public suffix list or from predefined suffix list
|
||||||
func (v *V) hasSuffix(domain string) bool {
|
func (v *V) hasSuffix(domain string) bool {
|
||||||
for _, suffix := range privateSuffix {
|
for _, suffix := range v.cfg.Domain.PrivateSuffixes {
|
||||||
if strings.HasSuffix(domain, suffix) {
|
if strings.HasSuffix(domain, suffix) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
197
vendor/gitlab.com/etke.cc/go/validator/v2/emails.go
generated
vendored
Normal file
197
vendor/gitlab.com/etke.cc/go/validator/v2/emails.go
generated
vendored
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"blitiri.com.ar/go/spf"
|
||||||
|
"gitlab.com/etke.cc/go/trysmtp"
|
||||||
|
"golang.org/x/net/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSpamlist = fmt.Errorf("spamlist")
|
||||||
|
ErrNoMX = fmt.Errorf("no MX")
|
||||||
|
ErrSMTP = fmt.Errorf("SMTP")
|
||||||
|
ErrSPF = fmt.Errorf("SPF")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Email checks if email is valid
|
||||||
|
// returnPath and optionalSenderIP are optional fields
|
||||||
|
func (v *V) Email(email, returnPath string, optionalSenderIP ...net.IP) bool {
|
||||||
|
// edge case: email may be optional
|
||||||
|
if email == "" {
|
||||||
|
return !v.cfg.Email.Enforce
|
||||||
|
}
|
||||||
|
|
||||||
|
address, err := mail.ParseAddress(email)
|
||||||
|
if err != nil {
|
||||||
|
v.cfg.Log("email %s invalid, reason: %v", email, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if returnPath != "" {
|
||||||
|
rpAddress, err := mail.ParseAddress(returnPath)
|
||||||
|
if err != nil {
|
||||||
|
v.cfg.Log("return path %s invalid, reason: %v", returnPath, err)
|
||||||
|
}
|
||||||
|
if rpAddress != nil {
|
||||||
|
returnPath = rpAddress.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
email = address.Address
|
||||||
|
return v.emailChecks(email, returnPath, optionalSenderIP...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *V) emailChecks(email, returnPath string, optionalSenderIP ...net.IP) bool {
|
||||||
|
maxChecks := 4
|
||||||
|
var senderIP net.IP
|
||||||
|
if len(optionalSenderIP) > 0 {
|
||||||
|
senderIP = optionalSenderIP[0]
|
||||||
|
}
|
||||||
|
errchan := make(chan error, maxChecks)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go v.emailSpamlist(ctx, email, errchan)
|
||||||
|
go v.emailNoMX(ctx, email, errchan)
|
||||||
|
go v.emailNoSPF(ctx, email, returnPath, senderIP, errchan)
|
||||||
|
go v.emailNoSMTP(ctx, email, errchan)
|
||||||
|
|
||||||
|
var checks int
|
||||||
|
for {
|
||||||
|
checks++
|
||||||
|
err := <-errchan
|
||||||
|
if err != nil {
|
||||||
|
v.cfg.Log("email %q is invalid, reason: %v", email, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if checks >= maxChecks {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *V) emailSpamlist(ctx context.Context, email string, errchan chan error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
emailb := []byte(email)
|
||||||
|
for _, spamregex := range v.cfg.Email.spamlist {
|
||||||
|
if spamregex.Match(emailb) {
|
||||||
|
errchan <- ErrSpamlist
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errchan <- nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *V) emailNoMX(ctx context.Context, email string, errchan chan error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
if !v.cfg.Email.MX {
|
||||||
|
errchan <- nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
at := strings.LastIndex(email, "@")
|
||||||
|
domain := email[at+1:]
|
||||||
|
if !v.MX(domain) {
|
||||||
|
v.cfg.Log("email %s domain %s invalid, reason: no MX", email, domain)
|
||||||
|
errchan <- ErrNoMX
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errchan <- nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *V) emailNoSMTP(ctx context.Context, email string, errchan chan error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
if !v.cfg.Email.SMTP {
|
||||||
|
errchan <- nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := trysmtp.Connect(v.cfg.Email.From, email)
|
||||||
|
if err != nil {
|
||||||
|
if strings.HasPrefix(err.Error(), "45") {
|
||||||
|
v.cfg.Log("email %s may be invalid, reason: SMTP check (%v)", email, err)
|
||||||
|
errchan <- nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v.cfg.Log("email %s invalid, reason: SMTP check (%v)", email, err)
|
||||||
|
errchan <- ErrSMTP
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.Close()
|
||||||
|
errchan <- nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldValidateReturnPath checks if returnPath should be validated in SPF check
|
||||||
|
func (v *V) shouldValidateReturnPath(email, returnPath string) bool {
|
||||||
|
if returnPath == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if email == returnPath {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailDomain, returnPathDomain string
|
||||||
|
emailParts := strings.Split(email, "@")
|
||||||
|
if len(emailParts) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
emailDomain = v.GetBase(emailParts[1])
|
||||||
|
|
||||||
|
if returnPath != "" {
|
||||||
|
returnPathParts := strings.Split(returnPath, "@")
|
||||||
|
if len(returnPathParts) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
returnPathDomain = v.GetBase(returnPathParts[1])
|
||||||
|
}
|
||||||
|
return emailDomain == returnPathDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *V) emailNoSPF(ctx context.Context, email, returnPath string, senderIP net.IP, errchan chan error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
if !v.cfg.Email.SPF {
|
||||||
|
errchan <- nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []spf.Option{
|
||||||
|
spf.WithContext(ctx),
|
||||||
|
spf.WithTraceFunc(v.cfg.Log),
|
||||||
|
}
|
||||||
|
|
||||||
|
resultEmail, _ := spf.CheckHostWithSender(senderIP, "", email, opts...) //nolint:errcheck // not a error
|
||||||
|
if resultEmail == spf.Fail {
|
||||||
|
if v.shouldValidateReturnPath(email, returnPath) {
|
||||||
|
resultReturnPath, _ := spf.CheckHostWithSender(senderIP, "", returnPath, opts...) //nolint:errcheck // not a error
|
||||||
|
if resultReturnPath == spf.Fail {
|
||||||
|
errchan <- ErrSPF
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errchan <- ErrSPF
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errchan <- nil
|
||||||
|
}
|
||||||
|
}
|
||||||
22
vendor/gitlab.com/etke.cc/go/validator/v2/justfile
generated
vendored
Normal file
22
vendor/gitlab.com/etke.cc/go/validator/v2/justfile
generated
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# show help by default
|
||||||
|
default:
|
||||||
|
@just --list --justfile {{ justfile() }}
|
||||||
|
|
||||||
|
# update go deps
|
||||||
|
update *flags:
|
||||||
|
go get {{flags}} .
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# run linter
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
# automatically fix liter issues
|
||||||
|
lintfix:
|
||||||
|
golangci-lint run --fix ./...
|
||||||
|
|
||||||
|
# run unit tests
|
||||||
|
test:
|
||||||
|
@go test -cover -coverprofile=cover.out -coverpkg=./... -covermode=set ./...
|
||||||
|
@go tool cover -func=cover.out
|
||||||
|
-@rm -f cover.out
|
||||||
80
vendor/gitlab.com/etke.cc/go/validator/v2/lookups.go
generated
vendored
Normal file
80
vendor/gitlab.com/etke.cc/go/validator/v2/lookups.go
generated
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A checks if host has at least one A record
|
||||||
|
func (v *V) A(host string) bool {
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
v.cfg.Log("cannot get A records of %s: %v", host, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(ips) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CNAME checks if host has at least one CNAME record
|
||||||
|
func (v *V) CNAME(host string) bool {
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cname, err := net.LookupCNAME(host)
|
||||||
|
if err != nil {
|
||||||
|
v.cfg.Log("cannot get CNAME records of %s: %v", host, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return cname != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MX checks if host has at least one MX record
|
||||||
|
func (v *V) MX(host string) bool {
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mxs, err := net.LookupMX(host)
|
||||||
|
if err != nil {
|
||||||
|
v.cfg.Log("cannot get MX records of %s: %v", host, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(mxs) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// NS checks if host has at least one NS record,
|
||||||
|
// and optionally checks if the NS record contains the given string
|
||||||
|
func (v *V) NS(host string, contains ...string) bool {
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nss, err := net.LookupNS(host)
|
||||||
|
if err != nil {
|
||||||
|
v.cfg.Log("cannot get NS records of %s: %v", host, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(nss) == 0 {
|
||||||
|
v.cfg.Log("%s doesn't have NS records", host)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(contains) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(contains) > 0 {
|
||||||
|
for _, ns := range nss {
|
||||||
|
for _, c := range contains {
|
||||||
|
if strings.Contains(ns.Host, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
56
vendor/gitlab.com/etke.cc/go/validator/v2/validator.go
generated
vendored
Normal file
56
vendor/gitlab.com/etke.cc/go/validator/v2/validator.go
generated
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// V is a validator implementation
|
||||||
|
type V struct {
|
||||||
|
cfg *Config
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultLogfunc = func(msg string, args ...any) {
|
||||||
|
fmt.Printf(msg+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New validator
|
||||||
|
func New(cfg *Config) *V {
|
||||||
|
if cfg.Log == nil {
|
||||||
|
cfg.Log = defaultLogfunc
|
||||||
|
}
|
||||||
|
spamregexes, err := parseSpamlist(cfg.Email.Spamlist)
|
||||||
|
if err != nil {
|
||||||
|
cfg.Log("cannot parse spamlist: %v", err)
|
||||||
|
}
|
||||||
|
cfg.Email.spamlist = spamregexes
|
||||||
|
return &V{cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSpamlist(patterns []string) ([]*regexp.Regexp, error) {
|
||||||
|
regexes := []*regexp.Regexp{}
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
rule, err := regexp.Compile("^" + parsePattern(pattern) + "$")
|
||||||
|
if err != nil {
|
||||||
|
return regexes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
regexes = append(regexes, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return regexes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePattern(pattern string) string {
|
||||||
|
var regexpattern strings.Builder
|
||||||
|
for _, runeItem := range pattern {
|
||||||
|
if runeItem == '*' {
|
||||||
|
regexpattern.WriteString("(.*)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
regexpattern.WriteString(regexp.QuoteMeta(string(runeItem)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return regexpattern.String()
|
||||||
|
}
|
||||||
75
vendor/gitlab.com/etke.cc/go/validator/validator.go
generated
vendored
75
vendor/gitlab.com/etke.cc/go/validator/validator.go
generated
vendored
|
|
@ -1,75 +0,0 @@
|
||||||
package validator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// V is a validator implementation
|
|
||||||
type V struct {
|
|
||||||
spamlist []*regexp.Regexp
|
|
||||||
enforce Enforce
|
|
||||||
from string
|
|
||||||
log Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce checks
|
|
||||||
type Enforce struct {
|
|
||||||
// Email enforces email check and rejects empty emails
|
|
||||||
Email bool
|
|
||||||
// Domain enforces domain check and rejects empty domains
|
|
||||||
Domain bool
|
|
||||||
// SMTP enforces SMTP check (email actually exists on mail server) and rejects non-existing emails
|
|
||||||
SMTP bool
|
|
||||||
// SPF enforces SPF record check (sender allowed to use that email and send emails) and rejects unathorized emails
|
|
||||||
SPF bool
|
|
||||||
// MX enforces MX records check on email's mail server
|
|
||||||
MX bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type Logger interface {
|
|
||||||
Info(string, ...interface{})
|
|
||||||
Error(string, ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// New Validator, accepts spamlist with wildcards
|
|
||||||
func New(spamlist []string, enforce Enforce, smtpFrom string, log Logger) *V {
|
|
||||||
spamregexes, err := parseSpamlist(spamlist)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("cannot parse spamlist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &V{
|
|
||||||
spamlist: spamregexes,
|
|
||||||
enforce: enforce,
|
|
||||||
from: smtpFrom,
|
|
||||||
log: log,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSpamlist(patterns []string) ([]*regexp.Regexp, error) {
|
|
||||||
regexes := []*regexp.Regexp{}
|
|
||||||
for _, pattern := range patterns {
|
|
||||||
rule, err := regexp.Compile("^" + parsePattern(pattern) + "$")
|
|
||||||
if err != nil {
|
|
||||||
return regexes, err
|
|
||||||
}
|
|
||||||
|
|
||||||
regexes = append(regexes, rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
return regexes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePattern(pattern string) string {
|
|
||||||
var regexpattern strings.Builder
|
|
||||||
for _, runeItem := range pattern {
|
|
||||||
if runeItem == '*' {
|
|
||||||
regexpattern.WriteString("(.*)")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
regexpattern.WriteString(regexp.QuoteMeta(string(runeItem)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return regexpattern.String()
|
|
||||||
}
|
|
||||||
4
vendor/modules.txt
vendored
4
vendor/modules.txt
vendored
|
|
@ -159,9 +159,9 @@ gitlab.com/etke.cc/go/secgen
|
||||||
# gitlab.com/etke.cc/go/trysmtp v1.1.3
|
# gitlab.com/etke.cc/go/trysmtp v1.1.3
|
||||||
## explicit; go 1.17
|
## explicit; go 1.17
|
||||||
gitlab.com/etke.cc/go/trysmtp
|
gitlab.com/etke.cc/go/trysmtp
|
||||||
# gitlab.com/etke.cc/go/validator v1.0.7
|
# gitlab.com/etke.cc/go/validator/v2 v2.2.0
|
||||||
## explicit; go 1.18
|
## explicit; go 1.18
|
||||||
gitlab.com/etke.cc/go/validator
|
gitlab.com/etke.cc/go/validator/v2
|
||||||
# gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54
|
# gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54
|
||||||
## explicit; go 1.21
|
## explicit; go 1.21
|
||||||
gitlab.com/etke.cc/linkpearl
|
gitlab.com/etke.cc/linkpearl
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue