diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c07dd97..dfeb403 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,7 +31,7 @@ jobs: # https://olegk.dev/github-actions-and-go run: gofmt -s -w . && git diff --exit-code - name: Run Go tests - run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck -v + run: go test -p 1 ./internal/storage ./server ./internal/smtpd ./internal/pop3 ./internal/tools ./internal/html2text ./internal/htmlcheck ./internal/linkcheck ./internal/shortuuid -v - name: Run Go benchmarking run: go test -p 1 ./internal/storage ./internal/html2text -bench=. diff --git a/go.mod b/go.mod index a175f19..746051c 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,12 @@ require ( github.com/axllent/semver v1.0.0 github.com/goccy/go-yaml v1.19.2 github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/jhillyerd/enmime/v2 v2.3.0 github.com/klauspost/compress v1.18.5 github.com/kovidgoyal/imaging v1.8.21 github.com/leporo/sqlf v1.4.0 - github.com/lithammer/shortuuid/v4 v4.2.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/rqlite/gorqlite v0.0.0-20250609141355-ac86a4a1c9a8 @@ -41,7 +41,6 @@ require ( github.com/fatih/color v1.19.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inbucket/html2text v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 9edff93..4c679ff 100644 --- a/go.sum +++ b/go.sum @@ -69,8 +69,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leporo/sqlf v1.4.0 h1:SyWnX/8GSGOzVmanG0Ub1c04mR9nNl6Tq3IeFKX2/4c= github.com/leporo/sqlf v1.4.0/go.mod h1:pgN9yKsAnQ+2ewhbZogr98RcasUjPsHF3oXwPPhHvBw= -github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= -github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= diff --git a/internal/shortuuid/shortuuid.go b/internal/shortuuid/shortuuid.go new file mode 100644 index 0000000..4d31f9c --- /dev/null +++ b/internal/shortuuid/shortuuid.go @@ -0,0 +1,53 @@ +// Package shortuuid provides a simple way to generate short, unique, alphanumeric identifiers. +// The generated IDs are 22 characters long and consist of uppercase letters, lowercase letters, and digits. +package shortuuid + +import ( + "encoding/binary" + "math/bits" + + "github.com/google/uuid" +) + +const ( + alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + length = 22 + nDigits = 10 + divisor = 839299365868340224 // 62^10, max power of 62 that fits in uint64 +) + +// New returns a 22-character alphanumeric unique identifier. +func New() string { + id := uuid.New() + num := [2]uint64{ + binary.BigEndian.Uint64(id[8:]), + binary.BigEndian.Uint64(id[:8]), + } + + buf := make([]byte, length) + var r uint64 + i := length - 1 + for num[1] > 0 || num[0] > 0 { + num, r = quoRem64(num, divisor) + for j := 0; j < nDigits && i >= 0; j++ { + buf[i] = alphabet[r%62] + r /= 62 + i-- + } + } + for ; i >= 0; i-- { + buf[i] = alphabet[0] + } + + return string(buf) +} + +// quoRem64 divides a 128-bit number (represented as [lo, hi] uint64) by v, +// returning the quotient and remainder. +func quoRem64(u [2]uint64, v uint64) ([2]uint64, uint64) { + var q [2]uint64 + var r uint64 + q[1], r = bits.Div64(0, u[1], v) + q[0], r = bits.Div64(r, u[0], v) + return q, r +} diff --git a/internal/shortuuid/shortuuid_test.go b/internal/shortuuid/shortuuid_test.go new file mode 100644 index 0000000..dd97069 --- /dev/null +++ b/internal/shortuuid/shortuuid_test.go @@ -0,0 +1,52 @@ +package shortuuid + +import ( + "regexp" + "testing" +) + +// alphanumeric matches IDs that contain only digits and ASCII letters. +var alphanumeric = regexp.MustCompile(`^[0-9A-Za-z]+$`) + +// TestLength verifies that every generated ID is exactly 22 characters long, +// including when the UUID encodes to a value with leading zero-padding. +func TestLength(t *testing.T) { + for range 100 { + id := New() + if len(id) != length { + t.Errorf("expected length %d, got %d: %q", length, len(id), id) + } + } +} + +// TestAlphanumeric verifies that no ID contains hyphens, underscores, or any +// other non-alphanumeric character that would be unsafe in a URL path segment. +func TestAlphanumeric(t *testing.T) { + for range 100 { + id := New() + if !alphanumeric.MatchString(id) { + t.Errorf("non-alphanumeric characters in ID: %q", id) + } + } +} + +// TestUnique verifies that IDs are unique across a large sample. Collisions are +// cryptographically implausible given the 122-bit UUID entropy, so any hit here +// indicates a bug in the encoding (e.g. truncation, constant output). +func TestUnique(t *testing.T) { + seen := make(map[string]struct{}, 1000000) + for range 1000000 { + id := New() + if _, exists := seen[id]; exists { + t.Fatalf("duplicate ID generated: %q", id) + } + seen[id] = struct{}{} + } +} + +// BenchmarkNew measures the cost of generating a single ID, including UUID generation. +func BenchmarkNew(b *testing.B) { + for b.Loop() { + _ = New() + } +} diff --git a/internal/smtpd/main.go b/internal/smtpd/main.go index 2f5a0e1..af3b4f3 100644 --- a/internal/smtpd/main.go +++ b/internal/smtpd/main.go @@ -12,11 +12,11 @@ import ( "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/auth" "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/shortuuid" "github.com/axllent/mailpit/internal/stats" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/server/websockets" - "github.com/lithammer/shortuuid/v4" "github.com/pkg/errors" ) diff --git a/internal/storage/messages.go b/internal/storage/messages.go index 3a24a71..bf60534 100644 --- a/internal/storage/messages.go +++ b/internal/storage/messages.go @@ -19,12 +19,12 @@ import ( "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/shortuuid" "github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/server/webhook" "github.com/axllent/mailpit/server/websockets" "github.com/jhillyerd/enmime/v2" "github.com/leporo/sqlf" - "github.com/lithammer/shortuuid/v4" ) // Store will save an email to the database tables. diff --git a/server/apiv1/release.go b/server/apiv1/release.go index 8883210..cd963bb 100644 --- a/server/apiv1/release.go +++ b/server/apiv1/release.go @@ -10,10 +10,10 @@ import ( "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/shortuuid" "github.com/axllent/mailpit/internal/smtpd" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" - "github.com/lithammer/shortuuid/v4" ) // ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server. diff --git a/server/server.go b/server/server.go index a41f82c..3e93114 100644 --- a/server/server.go +++ b/server/server.go @@ -21,6 +21,7 @@ import ( "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/pop3" "github.com/axllent/mailpit/internal/prometheus" + "github.com/axllent/mailpit/internal/shortuuid" "github.com/axllent/mailpit/internal/snakeoil" "github.com/axllent/mailpit/internal/stats" "github.com/axllent/mailpit/internal/storage" @@ -28,7 +29,6 @@ import ( "github.com/axllent/mailpit/server/apiv1" "github.com/axllent/mailpit/server/handlers" "github.com/axllent/mailpit/server/websockets" - "github.com/lithammer/shortuuid/v4" ) var (