From 849bdb53c513daeb54e8eca5533747a492073b58 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 12:06:53 +0700 Subject: [PATCH] Use base32 encoded UUID to reduce address size --- README.md | 6 +- api/openapi.yaml | 34 ++++++----- docker/postfix/transport_maps | 4 +- internal/api/handlers.go | 59 +++++++++++++++---- internal/receiver/receiver.go | 35 ++++++++++-- internal/utils/uuid.go | 75 +++++++++++++++++++++++++ pkg/analyzer/report.go | 5 +- pkg/analyzer/report_test.go | 9 ++- web/src/routes/test/[test]/+page.svelte | 2 +- 9 files changed, 188 insertions(+), 41 deletions(-) create mode 100644 internal/utils/uuid.go diff --git a/README.md b/README.md index b9db23c..a4ded59 100644 --- a/README.md +++ b/README.md @@ -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-@yourdomain.com -> LMTP on localhost:2525 + # Pattern: test-@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" } diff --git a/api/openapi.yaml b/api/openapi.yaml index d25c5c5..c569664 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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 diff --git a/docker/postfix/transport_maps b/docker/postfix/transport_maps index 49fdb98..cc1deed 100644 --- a/docker/postfix/transport_maps +++ b/docker/postfix/transport_maps @@ -1,4 +1,4 @@ # Transport map - route test emails to happyDeliver LMTP server -# Pattern: test-@domain.com -> LMTP on localhost:2525 +# Pattern: test-@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 diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b66db2d..b53c391 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -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{ diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 1132b54..fb8d36e 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -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-@domain.com +// Expected format: test-@domain.com func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) { // Remove angle brackets if present (e.g., ) 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 diff --git a/internal/utils/uuid.go b/internal/utils/uuid.go new file mode 100644 index 0000000..ebbbbdf --- /dev/null +++ b/internal/utils/uuid.go @@ -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 . +// +// 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 . + +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 +} diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index fe30c6c..d6a1e23 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -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, } diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 4a8fe00..fce4a64 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -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 { diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index ac89f78..ca4c6b0 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -73,7 +73,7 @@ - {test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."} + {test ? `Test ${test.id.slice(0, 7)} - happyDeliver` : "Loading..."}