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
|
# 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`:
|
3. Append the created file to `transport_maps` in your `main.cf`:
|
||||||
|
|
@ -144,7 +144,7 @@ Response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"email": "test-550e8400@localhost",
|
"email": "test-kfauqaao-ukj2if3n-fgrfkiafaa@localhost",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"message": "Send your test email to the address above"
|
"message": "Send your test email to the address above"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- tests
|
- tests
|
||||||
summary: Get test status
|
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
|
operationId: getTest
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
|
|
@ -60,7 +60,8 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
pattern: '^[a-z0-9-]+$'
|
||||||
|
description: Base32-encoded test ID (with hyphens)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Test status retrieved successfully
|
description: Test status retrieved successfully
|
||||||
|
|
@ -88,7 +89,8 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
pattern: '^[a-z0-9-]+$'
|
||||||
|
description: Base32-encoded test ID (with hyphens)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Report retrieved successfully
|
description: Report retrieved successfully
|
||||||
|
|
@ -116,7 +118,8 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
pattern: '^[a-z0-9-]+$'
|
||||||
|
description: Base32-encoded test ID (with hyphens)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Raw email retrieved successfully
|
description: Raw email retrieved successfully
|
||||||
|
|
@ -157,14 +160,14 @@ components:
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
pattern: '^[a-z0-9-]+$'
|
||||||
description: Unique test identifier
|
description: Unique test identifier (base32-encoded with hyphens)
|
||||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a"
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
description: Unique test email address
|
description: Unique test email address
|
||||||
example: "test-550e8400@example.com"
|
example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com"
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending, analyzed]
|
enum: [pending, analyzed]
|
||||||
|
|
@ -180,12 +183,13 @@ components:
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
pattern: '^[a-z0-9-]+$'
|
||||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
description: Unique test identifier (base32-encoded with hyphens)
|
||||||
|
example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a"
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
example: "test-550e8400@example.com"
|
example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com"
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [pending]
|
enum: [pending]
|
||||||
|
|
@ -205,12 +209,12 @@ components:
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
pattern: '^[a-z0-9-]+$'
|
||||||
description: Report identifier
|
description: Report identifier (base32-encoded with hyphens)
|
||||||
test_id:
|
test_id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
pattern: '^[a-z0-9-]+$'
|
||||||
description: Associated test ID
|
description: Associated test ID (base32-encoded with hyphens)
|
||||||
score:
|
score:
|
||||||
type: number
|
type: number
|
||||||
format: float
|
format: float
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Transport map - route test emails to happyDeliver LMTP server
|
# 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/config"
|
||||||
"git.happydns.org/happyDeliver/internal/storage"
|
"git.happydns.org/happyDeliver/internal/storage"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// APIHandler implements the ServerInterface for handling API requests
|
// 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)
|
// Generate a unique test ID (no database record created)
|
||||||
testID := uuid.New()
|
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",
|
email := fmt.Sprintf("%s%s@%s",
|
||||||
h.config.Email.TestAddressPrefix,
|
h.config.Email.TestAddressPrefix,
|
||||||
testID.String(),
|
base32ID,
|
||||||
h.config.Email.Domain,
|
h.config.Email.Domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Return response
|
// Return response
|
||||||
c.JSON(http.StatusCreated, TestResponse{
|
c.JSON(http.StatusCreated, TestResponse{
|
||||||
Id: testID,
|
Id: base32ID,
|
||||||
Email: openapi_types.Email(email),
|
Email: openapi_types.Email(email),
|
||||||
Status: TestResponseStatusPending,
|
Status: TestResponseStatusPending,
|
||||||
Message: stringPtr("Send your test email to the given address"),
|
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
|
// GetTest retrieves test metadata
|
||||||
// (GET /test/{id})
|
// (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
|
// Check if a report exists for this test ID
|
||||||
reportExists, err := h.storage.ReportExists(id)
|
reportExists, err := h.storage.ReportExists(testUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
c.JSON(http.StatusInternalServerError, Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
|
|
@ -94,10 +109,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id openapi_types.UUID) {
|
||||||
apiStatus = TestStatusPending
|
apiStatus = TestStatusPending
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate test email address
|
// Generate test email address using Base32-encoded UUID
|
||||||
email := fmt.Sprintf("%s%s@%s",
|
email := fmt.Sprintf("%s%s@%s",
|
||||||
h.config.Email.TestAddressPrefix,
|
h.config.Email.TestAddressPrefix,
|
||||||
id.String(),
|
id,
|
||||||
h.config.Email.Domain,
|
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
|
// GetReport retrieves the detailed analysis report
|
||||||
// (GET /report/{id})
|
// (GET /report/{id})
|
||||||
func (h *APIHandler) GetReport(c *gin.Context, id openapi_types.UUID) {
|
func (h *APIHandler) GetReport(c *gin.Context, id string) {
|
||||||
reportJSON, _, err := h.storage.GetReport(id)
|
// 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 != nil {
|
||||||
if err == storage.ErrNotFound {
|
if err == storage.ErrNotFound {
|
||||||
c.JSON(http.StatusNotFound, Error{
|
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
|
// GetRawEmail retrieves the raw annotated email
|
||||||
// (GET /report/{id}/raw)
|
// (GET /report/{id}/raw)
|
||||||
func (h *APIHandler) GetRawEmail(c *gin.Context, id openapi_types.UUID) {
|
func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
|
||||||
_, rawEmail, err := h.storage.GetReport(id)
|
// 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 != nil {
|
||||||
if err == storage.ErrNotFound {
|
if err == storage.ErrNotFound {
|
||||||
c.JSON(http.StatusNotFound, Error{
|
c.JSON(http.StatusNotFound, Error{
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
package receiver
|
package receiver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base32"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -112,8 +113,34 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
|
||||||
return nil
|
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
|
// 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) {
|
func (r *EmailReceiver) extractTestID(email string) (uuid.UUID, error) {
|
||||||
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
|
// Remove angle brackets if present (e.g., <test-uuid@domain.com>)
|
||||||
email = strings.Trim(email, "<>")
|
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)
|
uuidStr := strings.TrimPrefix(localPart, r.config.Email.TestAddressPrefix)
|
||||||
|
|
||||||
// Parse UUID
|
// Decode Base32 to UUID
|
||||||
testID, err := uuid.Parse(uuidStr)
|
testID, err := base32ToUUID(uuidStr)
|
||||||
if err != nil {
|
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
|
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"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -96,8 +97,8 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
report := &api.Report{
|
report := &api.Report{
|
||||||
Id: reportID,
|
Id: utils.UUIDToBase32(reportID),
|
||||||
TestId: testID,
|
TestId: utils.UUIDToBase32(testID),
|
||||||
Score: results.Score.OverallScore,
|
Score: results.Score.OverallScore,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -106,12 +107,14 @@ func TestGenerateReport(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify required fields
|
// Verify required fields
|
||||||
if report.Id == uuid.Nil {
|
if report.Id == "" {
|
||||||
t.Error("Report ID should not be empty")
|
t.Error("Report ID should not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
if report.TestId != testID {
|
// Convert testID to base32 for comparison
|
||||||
t.Errorf("TestId = %s, want %s", report.TestId, testID)
|
expectedTestID := utils.UUIDToBase32(testID)
|
||||||
|
if report.TestId != expectedTestID {
|
||||||
|
t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if report.Score < 0 || report.Score > 10 {
|
if report.Score < 0 || report.Score > 10 {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<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>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="container py-5">
|
<div class="container py-5">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue