Use base32 encoded UUID to reduce address size
This commit is contained in:
parent
3f5e2c6dd4
commit
849bdb53c5
9 changed files with 188 additions and 41 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
75
internal/utils/uuid.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue