Use base32 encoded UUID to reduce address size

This commit is contained in:
nemunaire 2025-10-20 12:06:53 +07:00
commit 849bdb53c5
9 changed files with 188 additions and 41 deletions

View file

@ -108,9 +108,9 @@ You'll obtain the best results with a custom [transport rule](https://www.postfi
```
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<uuid>@yourdomain.com -> LMTP on localhost:2525
# Pattern: test-<base32-uuid>@yourdomain.com -> LMTP on localhost:2525
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
/^test-[a-zA-Z2-7-]{26,30}@yourdomain\.com$/ lmtp:inet:127.0.0.1:2525
```
3. Append the created file to `transport_maps` in your `main.cf`:
@ -144,7 +144,7 @@ Response:
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "test-550e8400@localhost",
"email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost",
"status": "pending",
"message": "Send your test email to the address above"
}

View file

@ -52,7 +52,7 @@ paths:
tags:
- tests
summary: Get test status
description: Check if a report exists for the given test ID. Returns pending if no report exists, analyzed if a report is available.
description: Check if a report exists for the given test ID (base32-encoded). Returns pending if no report exists, analyzed if a report is available.
operationId: getTest
parameters:
- name: id
@ -60,7 +60,8 @@ paths:
required: true
schema:
type: string
format: uuid
pattern: '^[a-z0-9-]+$'
description: Base32-encoded test ID (with hyphens)
responses:
'200':
description: Test status retrieved successfully
@ -88,7 +89,8 @@ paths:
required: true
schema:
type: string
format: uuid
pattern: '^[a-z0-9-]+$'
description: Base32-encoded test ID (with hyphens)
responses:
'200':
description: Report retrieved successfully
@ -116,7 +118,8 @@ paths:
required: true
schema:
type: string
format: uuid
pattern: '^[a-z0-9-]+$'
description: Base32-encoded test ID (with hyphens)
responses:
'200':
description: Raw email retrieved successfully
@ -157,14 +160,14 @@ components:
properties:
id:
type: string
format: uuid
description: Unique test identifier
example: "550e8400-e29b-41d4-a716-446655440000"
pattern: '^[a-z0-9-]+$'
description: Unique test identifier (base32-encoded with hyphens)
example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a"
email:
type: string
format: email
description: Unique test email address
example: "test-550e8400@example.com"
example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com"
status:
type: string
enum: [pending, analyzed]
@ -180,12 +183,13 @@ components:
properties:
id:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
pattern: '^[a-z0-9-]+$'
description: Unique test identifier (base32-encoded with hyphens)
example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a"
email:
type: string
format: email
example: "test-550e8400@example.com"
example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com"
status:
type: string
enum: [pending]
@ -205,12 +209,12 @@ components:
properties:
id:
type: string
format: uuid
description: Report identifier
pattern: '^[a-z0-9-]+$'
description: Report identifier (base32-encoded with hyphens)
test_id:
type: string
format: uuid
description: Associated test ID
pattern: '^[a-z0-9-]+$'
description: Associated test ID (base32-encoded with hyphens)
score:
type: number
format: float

View file

@ -1,4 +1,4 @@
# Transport map - route test emails to happyDeliver LMTP server
# Pattern: test-<uuid>@domain.com -> LMTP on localhost:2525
# Pattern: test-<base32-uuid>@domain.com -> LMTP on localhost:2525
/^test-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}@.*$/ lmtp:inet:127.0.0.1:2525
/^test-[a-zA-Z2-7-]{26,30}@.*$/ lmtp:inet:127.0.0.1:2525

View file

@ -32,6 +32,7 @@ import (
"git.happydns.org/happyDeliver/internal/config"
"git.happydns.org/happyDeliver/internal/storage"
"git.happydns.org/happyDeliver/internal/utils"
)
// APIHandler implements the ServerInterface for handling API requests
@ -56,16 +57,19 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
// Generate a unique test ID (no database record created)
testID := uuid.New()
// Generate test email address
// Convert UUID to base32 string for the API response
base32ID := utils.UUIDToBase32(testID)
// Generate test email address using Base32-encoded UUID
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
testID.String(),
base32ID,
h.config.Email.Domain,
)
// Return response
c.JSON(http.StatusCreated, TestResponse{
Id: testID,
Id: base32ID,
Email: openapi_types.Email(email),
Status: TestResponseStatusPending,
Message: stringPtr("Send your test email to the given address"),
@ -74,9 +78,20 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
// GetTest retrieves test metadata
// (GET /test/{id})
func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
func (h *APIHandler) GetTest(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
})
return
}
// Check if a report exists for this test ID
reportExists, err := h.storage.ReportExists(id)
reportExists, err := h.storage.ReportExists(testUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, Error{
Error: "internal_error",
@ -94,10 +109,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
apiStatus = TestStatusPending
}
// Generate test email address
// Generate test email address using Base32-encoded UUID
email := fmt.Sprintf("%s%s@%s",
h.config.Email.TestAddressPrefix,
id.String(),
id,
h.config.Email.Domain,
)
@ -110,8 +125,19 @@ func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
// GetReport retrieves the detailed analysis report
// (GET /report/{id})
func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
reportJSON, _, err := h.storage.GetReport(id)
func (h *APIHandler) GetReport(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
})
return
}
reportJSON, _, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{
@ -134,8 +160,19 @@ func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
// GetRawEmail retrieves the raw annotated email
// (GET /report/{id}/raw)
func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
_, rawEmail, err := h.storage.GetReport(id)
func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
// Convert base32 ID to UUID
testUUID, err := utils.Base32ToUUID(id)
if err != nil {
c.JSON(http.StatusBadRequest, Error{
Error: "invalid_id",
Message: "Invalid test ID format",
Details: stringPtr(err.Error()),
})
return
}
_, rawEmail, err := h.storage.GetReport(testUUID)
if err != nil {
if err == storage.ErrNotFound {
c.JSON(http.StatusNotFound, Error{

View file

@ -22,6 +22,7 @@
package receiver
import (
"encoding/base32"
"encoding/json"
"fmt"
"io"
@ -112,8 +113,34 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return nil
}
// base32ToUUID converts a URL-safe Base32 string (without padding) to a UUID
// Hyphens are ignored during decoding
func base32ToUUID(encoded string) (uuid.UUID, error) {
// Remove hyphens for decoding
encoded = strings.ReplaceAll(encoded, "-", "")
// Convert to uppercase for Base32 decoding
encoded = strings.ToUpper(encoded)
// Decode from Base32
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to decode base32: %w", err)
}
// Ensure we have exactly 16 bytes for UUID
if len(decoded) != 16 {
return uuid.Nil, fmt.Errorf("decoded bytes length is %d, expected 16", len(decoded))
}
// Convert bytes to UUID
var id uuid.UUID
copy(id[:], decoded)
return id, nil
}
// extractTestID extracts the UUID from the test email address
// Expected format: test-<uuid>@domain.com
// Expected format: test-<base32-uuid>@domain.com
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
email = strings.Trim(email, "<>")
@ -133,10 +160,10 @@ func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
// Parse UUID
testID, err := uuid.Parse(uuidStr)
// Decode Base32 to UUID
testID, err := base32ToUUID(uuidStr)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid UUID in email address: %s", uuidStr)
return uuid.Nil, fmt.Errorf("invalid Base32 encoding in email address: %s - %w", uuidStr, err)
}
return testID, nil

75
internal/utils/uuid.go Normal file
View file

@ -0,0 +1,75 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package utils
import (
"encoding/base32"
"fmt"
"strings"
"github.com/google/uuid"
)
// UUIDToBase32 converts a UUID to a URL-safe Base32 string (without padding)
// with hyphens every 7 characters for better readability
func UUIDToBase32(id uuid.UUID) string {
// Use RFC 4648 Base32 encoding (URL-safe)
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(id[:])
// Convert to lowercase for better readability
encoded = strings.ToLower(encoded)
// Insert hyphens every 7 characters
var result strings.Builder
for i, char := range encoded {
if i > 0 && i%7 == 0 {
result.WriteRune('-')
}
result.WriteRune(char)
}
return result.String()
}
// Base32ToUUID converts a base32-encoded string back to a UUID
// Accepts strings with or without hyphens
func Base32ToUUID(encoded string) (uuid.UUID, error) {
// Remove hyphens
encoded = strings.ReplaceAll(encoded, "-", "")
// Convert to uppercase for decoding
encoded = strings.ToUpper(encoded)
// Decode base32
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(encoded)
if err != nil {
return uuid.UUID{}, fmt.Errorf("invalid base32 encoding: %w", err)
}
// Ensure we have exactly 16 bytes for a UUID
if len(decoded) != 16 {
return uuid.UUID{}, fmt.Errorf("invalid UUID length: expected 16 bytes, got %d", len(decoded))
}
// Convert byte slice to UUID
var id uuid.UUID
copy(id[:], decoded)
return id, nil
}

View file

@ -25,6 +25,7 @@ import (
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
@ -96,8 +97,8 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
now := time.Now()
report := &api.Report{
Id: reportID,
TestId: testID,
Id: utils.UUIDToBase32(reportID),
TestId: utils.UUIDToBase32(testID),
Score: results.Score.OverallScore,
CreatedAt: now,
}

View file

@ -29,6 +29,7 @@ import (
"time"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/utils"
"github.com/google/uuid"
)
@ -106,12 +107,14 @@ func TestGenerateReport(t *testing.T) {
}
// Verify required fields
if report.Id == uuid.Nil {
if report.Id == "" {
t.Error("Report ID should not be empty")
}
if report.TestId != testID {
t.Errorf("TestId = %s, want %s", report.TestId, testID)
// Convert testID to base32 for comparison
expectedTestID := utils.UUIDToBase32(testID)
if report.TestId != expectedTestID {
t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID)
}
if report.Score < 0 || report.Score > 10 {

View file

@ -73,7 +73,7 @@
</script>
<svelte:head>
<title>{test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."}</title>
<title>{test ? `Test ${test.id.slice(0, 7)} - happyDeliver` : "Loading..."}</title>
</svelte:head>
<div class="container py-5">