Compare commits
1 commit
79e1c930f1
...
d592c71cb2
| Author | SHA1 | Date | |
|---|---|---|---|
| d592c71cb2 |
15 changed files with 7 additions and 550 deletions
|
|
@ -76,49 +76,6 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
/tests:
|
|
||||||
get:
|
|
||||||
tags:
|
|
||||||
- tests
|
|
||||||
summary: List all tests
|
|
||||||
description: Returns a paginated list of test summaries with scores and grades. Can be disabled via server configuration.
|
|
||||||
operationId: listTests
|
|
||||||
parameters:
|
|
||||||
- name: offset
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
default: 0
|
|
||||||
description: Number of items to skip
|
|
||||||
- name: limit
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 1
|
|
||||||
maximum: 100
|
|
||||||
default: 20
|
|
||||||
description: Maximum number of items to return
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: List of test summaries
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/TestListResponse'
|
|
||||||
'403':
|
|
||||||
description: Test listing is disabled
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
'500':
|
|
||||||
description: Internal server error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
|
|
||||||
/report/{id}:
|
/report/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -1408,53 +1365,3 @@ components:
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/BlacklistCheck'
|
$ref: '#/components/schemas/BlacklistCheck'
|
||||||
description: List of DNS whitelist check results (informational only)
|
description: List of DNS whitelist check results (informational only)
|
||||||
|
|
||||||
TestSummary:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- test_id
|
|
||||||
- score
|
|
||||||
- grade
|
|
||||||
- created_at
|
|
||||||
properties:
|
|
||||||
test_id:
|
|
||||||
type: string
|
|
||||||
pattern: '^[a-z0-9-]+$'
|
|
||||||
description: Test identifier (base32-encoded with hyphens)
|
|
||||||
score:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
maximum: 100
|
|
||||||
description: Overall deliverability score (0-100)
|
|
||||||
grade:
|
|
||||||
type: string
|
|
||||||
enum: [A+, A, B, C, D, E, F]
|
|
||||||
description: Letter grade
|
|
||||||
from_domain:
|
|
||||||
type: string
|
|
||||||
description: Sender domain extracted from the report
|
|
||||||
created_at:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
|
|
||||||
TestListResponse:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- tests
|
|
||||||
- total
|
|
||||||
- offset
|
|
||||||
- limit
|
|
||||||
properties:
|
|
||||||
tests:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/TestSummary'
|
|
||||||
total:
|
|
||||||
type: integer
|
|
||||||
description: Total number of tests
|
|
||||||
offset:
|
|
||||||
type: integer
|
|
||||||
description: Current offset
|
|
||||||
limit:
|
|
||||||
type: integer
|
|
||||||
description: Current limit
|
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -7,7 +7,7 @@ require (
|
||||||
github.com/emersion/go-smtp v0.24.0
|
github.com/emersion/go-smtp v0.24.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/oapi-codegen/runtime v1.4.0
|
github.com/oapi-codegen/runtime v1.3.1
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.52.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -127,8 +127,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
||||||
github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4=
|
github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g=
|
||||||
github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec=
|
github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||||
|
|
|
||||||
|
|
@ -381,78 +381,3 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTests returns a paginated list of test summaries
|
|
||||||
// (GET /tests)
|
|
||||||
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
|
|
||||||
if h.config.DisableTestList {
|
|
||||||
c.JSON(http.StatusForbidden, Error{
|
|
||||||
Error: "feature_disabled",
|
|
||||||
Message: "Test listing is disabled on this instance",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := 0
|
|
||||||
limit := 20
|
|
||||||
if params.Offset != nil {
|
|
||||||
offset = *params.Offset
|
|
||||||
}
|
|
||||||
if params.Limit != nil {
|
|
||||||
limit = *params.Limit
|
|
||||||
if limit > 100 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summaries, total, err := h.storage.ListReportSummaries(offset, limit)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
|
||||||
Error: "internal_error",
|
|
||||||
Message: "Failed to list tests",
|
|
||||||
Details: stringPtr(err.Error()),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := make([]TestSummary, 0, len(summaries))
|
|
||||||
for _, s := range summaries {
|
|
||||||
base32ID := utils.UUIDToBase32(s.TestID)
|
|
||||||
|
|
||||||
var grade TestSummaryGrade
|
|
||||||
switch s.Grade {
|
|
||||||
case "A+":
|
|
||||||
grade = TestSummaryGradeA
|
|
||||||
case "A":
|
|
||||||
grade = TestSummaryGradeA1
|
|
||||||
case "B":
|
|
||||||
grade = TestSummaryGradeB
|
|
||||||
case "C":
|
|
||||||
grade = TestSummaryGradeC
|
|
||||||
case "D":
|
|
||||||
grade = TestSummaryGradeD
|
|
||||||
case "E":
|
|
||||||
grade = TestSummaryGradeE
|
|
||||||
default:
|
|
||||||
grade = TestSummaryGradeF
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := TestSummary{
|
|
||||||
TestId: base32ID,
|
|
||||||
Score: s.Score,
|
|
||||||
Grade: grade,
|
|
||||||
CreatedAt: s.CreatedAt,
|
|
||||||
}
|
|
||||||
if s.FromDomain != "" {
|
|
||||||
summary.FromDomain = stringPtr(s.FromDomain)
|
|
||||||
}
|
|
||||||
tests = append(tests, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, TestListResponse{
|
|
||||||
Tests: tests,
|
|
||||||
Total: int(total),
|
|
||||||
Offset: offset,
|
|
||||||
Limit: limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ func declareFlags(o *Config) {
|
||||||
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
|
flag.UintVar(&o.RateLimit, "rate-limit", o.RateLimit, "API rate limit (requests per second per IP)")
|
||||||
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
flag.Var(&URL{&o.SurveyURL}, "survey-url", "URL for user feedback survey")
|
||||||
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI")
|
flag.StringVar(&o.CustomLogoURL, "custom-logo-url", o.CustomLogoURL, "URL for custom logo image in the web UI")
|
||||||
flag.BoolVar(&o.DisableTestList, "disable-test-list", o.DisableTestList, "Disable the public test listing endpoint")
|
|
||||||
|
|
||||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ type Config struct {
|
||||||
RateLimit uint // API rate limit (requests per second per IP)
|
RateLimit uint // API rate limit (requests per second per IP)
|
||||||
SurveyURL url.URL // URL for user feedback survey
|
SurveyURL url.URL // URL for user feedback survey
|
||||||
CustomLogoURL string // URL for custom logo image in the web UI
|
CustomLogoURL string // URL for custom logo image in the web UI
|
||||||
DisableTestList bool // Disable the public test listing endpoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig contains database connection settings
|
// DatabaseConfig contains database connection settings
|
||||||
|
|
|
||||||
|
|
@ -45,21 +45,11 @@ type Storage interface {
|
||||||
ReportExists(testID uuid.UUID) (bool, error)
|
ReportExists(testID uuid.UUID) (bool, error)
|
||||||
UpdateReport(testID uuid.UUID, reportJSON []byte) error
|
UpdateReport(testID uuid.UUID, reportJSON []byte) error
|
||||||
DeleteOldReports(olderThan time.Time) (int64, error)
|
DeleteOldReports(olderThan time.Time) (int64, error)
|
||||||
ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error)
|
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportSummary is a lightweight projection of Report for listing
|
|
||||||
type ReportSummary struct {
|
|
||||||
TestID uuid.UUID
|
|
||||||
Score int
|
|
||||||
Grade string
|
|
||||||
FromDomain string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBStorage implements Storage using GORM
|
// DBStorage implements Storage using GORM
|
||||||
type DBStorage struct {
|
type DBStorage struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
|
@ -149,47 +139,6 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
|
||||||
return result.RowsAffected, nil
|
return result.RowsAffected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListReportSummaries returns a paginated list of lightweight report summaries
|
|
||||||
func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) {
|
|
||||||
var total int64
|
|
||||||
if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("failed to count reports: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if total == 0 {
|
|
||||||
return []ReportSummary{}, 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectExpr string
|
|
||||||
switch s.db.Dialector.Name() {
|
|
||||||
case "postgres":
|
|
||||||
selectExpr = `test_id, ` +
|
|
||||||
`(convert_from(report_json, 'UTF8')::jsonb->>'score')::int as score, ` +
|
|
||||||
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
|
|
||||||
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
|
|
||||||
`created_at`
|
|
||||||
default: // sqlite
|
|
||||||
selectExpr = `test_id, ` +
|
|
||||||
`json_extract(report_json, '$.score') as score, ` +
|
|
||||||
`json_extract(report_json, '$.grade') as grade, ` +
|
|
||||||
`json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
|
|
||||||
`created_at`
|
|
||||||
}
|
|
||||||
|
|
||||||
var summaries []ReportSummary
|
|
||||||
err := s.db.Model(&Report{}).
|
|
||||||
Select(selectExpr).
|
|
||||||
Order("created_at DESC").
|
|
||||||
Offset(offset).
|
|
||||||
Limit(limit).
|
|
||||||
Scan(&summaries).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return summaries, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
func (s *DBStorage) Close() error {
|
func (s *DBStorage) Close() error {
|
||||||
sqlDB, err := s.db.DB()
|
sqlDB, err := s.db.DB()
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
||||||
appConfig["custom_logo_url"] = cfg.CustomLogoURL
|
appConfig["custom_logo_url"] = cfg.CustomLogoURL
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cfg.DisableTestList {
|
|
||||||
appConfig["test_list_enabled"] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil {
|
||||||
log.Println("Unable to generate JSON config to inject in web application")
|
log.Println("Unable to generate JSON config to inject in web application")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -99,7 +95,6 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) {
|
||||||
router.GET("/domain/:domain", serveOrReverse("/", cfg))
|
router.GET("/domain/:domain", serveOrReverse("/", cfg))
|
||||||
router.GET("/test/", serveOrReverse("/", cfg))
|
router.GET("/test/", serveOrReverse("/", cfg))
|
||||||
router.GET("/test/:testid", serveOrReverse("/", cfg))
|
router.GET("/test/:testid", serveOrReverse("/", cfg))
|
||||||
router.GET("/history/", serveOrReverse("/", cfg))
|
|
||||||
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
router.GET("/favicon.png", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||||
router.GET("/img/*path", serveOrReverse("", cfg))
|
router.GET("/img/*path", serveOrReverse("", cfg))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
headerScore?: number;
|
headerScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
|
let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card shadow-sm" id="header-details">
|
<div class="card shadow-sm" id="header-details">
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
import type { TestSummary } from "$lib/api/types.gen";
|
|
||||||
import GradeDisplay from "./GradeDisplay.svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tests: TestSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let { tests }: Props = $props();
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="table-responsive shadow-sm">
|
|
||||||
<table class="table table-hover mb-0 align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="ps-4" style="width: 80px;">Grade</th>
|
|
||||||
<th style="width: 80px;">Score</th>
|
|
||||||
<th>Domain</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th style="width: 50px;"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each tests as test}
|
|
||||||
<tr class="cursor-pointer" onclick={() => goto(`/test/${test.test_id}`)}>
|
|
||||||
<td class="ps-4">
|
|
||||||
<GradeDisplay grade={test.grade} size="small" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-secondary">{test.score}%</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{#if test.from_domain}
|
|
||||||
<code>{test.from_domain}</code>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted">-</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">
|
|
||||||
{formatDate(test.created_at)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<i class="bi bi-chevron-right text-muted"></i>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.cursor-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor-pointer:hover td {
|
|
||||||
background-color: var(--bs-tertiary-bg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -23,6 +23,5 @@ export { default as RspamdCard } from "./RspamdCard.svelte";
|
||||||
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
|
||||||
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte";
|
||||||
export { default as SummaryCard } from "./SummaryCard.svelte";
|
export { default as SummaryCard } from "./SummaryCard.svelte";
|
||||||
export { default as HistoryTable } from "./HistoryTable.svelte";
|
|
||||||
export { default as TinySurvey } from "./TinySurvey.svelte";
|
export { default as TinySurvey } from "./TinySurvey.svelte";
|
||||||
export { default as WhitelistCard } from "./WhitelistCard.svelte";
|
export { default as WhitelistCard } from "./WhitelistCard.svelte";
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ interface AppConfig {
|
||||||
survey_url?: string;
|
survey_url?: string;
|
||||||
custom_logo_url?: string;
|
custom_logo_url?: string;
|
||||||
rbls?: string[];
|
rbls?: string[];
|
||||||
test_list_enabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: AppConfig = {
|
const defaultConfig: AppConfig = {
|
||||||
|
|
|
||||||
|
|
@ -40,17 +40,7 @@
|
||||||
<Logo color={$theme === "light" ? "black" : "white"} />
|
<Logo color={$theme === "light" ? "black" : "white"} />
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{#if $appConfig.test_list_enabled}
|
<div>
|
||||||
<ul class="navbar-nav me-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="/history/">
|
|
||||||
<i class="bi bi-clock-history me-1"></i>
|
|
||||||
History
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span class="d-none d-md-inline navbar-text text-primary small">
|
<span class="d-none d-md-inline navbar-text text-primary small">
|
||||||
Open-Source Email Deliverability Tester
|
Open-Source Email Deliverability Tester
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
import { createTest as apiCreateTest, listTests } from "$lib/api";
|
import { createTest as apiCreateTest } from "$lib/api";
|
||||||
import type { TestSummary } from "$lib/api/types.gen";
|
import { FeatureCard, HowItWorksStep } from "$lib/components";
|
||||||
import { FeatureCard, HowItWorksStep, HistoryTable } from "$lib/components";
|
|
||||||
import { appConfig } from "$lib/stores/config";
|
import { appConfig } from "$lib/stores/config";
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let recentTests = $state<TestSummary[]>([]);
|
|
||||||
|
|
||||||
async function loadRecentTests() {
|
|
||||||
if (!$appConfig.test_list_enabled) return;
|
|
||||||
try {
|
|
||||||
const response = await listTests({ query: { offset: 0, limit: 5 } });
|
|
||||||
if (response.data) {
|
|
||||||
recentTests = response.data.tests;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore — this is a non-critical section
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loadRecentTests();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createTest() {
|
async function createTest() {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
@ -194,32 +176,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Recently Tested -->
|
|
||||||
{#if $appConfig.test_list_enabled && recentTests.length > 0}
|
|
||||||
<section class="py-5 border-bottom border-3" id="recent">
|
|
||||||
<div class="container py-4">
|
|
||||||
<div class="row text-center mb-5">
|
|
||||||
<div class="col-lg-8 mx-auto">
|
|
||||||
<h2 class="display-5 fw-bold mb-3">Recently Tested</h2>
|
|
||||||
<p class="text-muted">Latest deliverability reports from this instance</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-10 mx-auto">
|
|
||||||
<HistoryTable tests={recentTests} />
|
|
||||||
<div class="text-center mt-4">
|
|
||||||
<a href="/history/" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-clock-history me-2"></i>
|
|
||||||
View All Tests
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
<!-- Features Section -->
|
||||||
<section class="py-5" id="features">
|
<section class="py-5" id="features">
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
import { listTests, createTest as apiCreateTest } from "$lib/api";
|
|
||||||
import type { TestSummary } from "$lib/api/types.gen";
|
|
||||||
import { HistoryTable } from "$lib/components";
|
|
||||||
|
|
||||||
let tests = $state<TestSummary[]>([]);
|
|
||||||
let total = $state(0);
|
|
||||||
let offset = $state(0);
|
|
||||||
let limit = $state(20);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
let creatingTest = $state(false);
|
|
||||||
|
|
||||||
async function loadTests() {
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await listTests({ query: { offset, limit } });
|
|
||||||
if (response.data) {
|
|
||||||
tests = response.data.tests;
|
|
||||||
total = response.data.total;
|
|
||||||
} else if (response.error) {
|
|
||||||
if (
|
|
||||||
response.error &&
|
|
||||||
typeof response.error === "object" &&
|
|
||||||
"error" in response.error &&
|
|
||||||
response.error.error === "feature_disabled"
|
|
||||||
) {
|
|
||||||
error = "Test listing is disabled on this instance.";
|
|
||||||
} else {
|
|
||||||
error = "Failed to load tests.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : "Failed to load tests.";
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loadTests();
|
|
||||||
});
|
|
||||||
|
|
||||||
function goToPage(newOffset: number) {
|
|
||||||
offset = newOffset;
|
|
||||||
loadTests();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTest() {
|
|
||||||
creatingTest = true;
|
|
||||||
try {
|
|
||||||
const response = await apiCreateTest();
|
|
||||||
if (response.data) {
|
|
||||||
goto(`/test/${response.data.id}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : "Failed to create test";
|
|
||||||
} finally {
|
|
||||||
creatingTest = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalPages = $derived(Math.ceil(total / limit));
|
|
||||||
let currentPage = $derived(Math.floor(offset / limit) + 1);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Test History - happyDeliver</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-10 mx-auto">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1 class="display-6 fw-bold mb-0">
|
|
||||||
<i class="bi bi-clock-history me-2"></i>
|
|
||||||
Test History
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
onclick={createTest}
|
|
||||||
disabled={creatingTest}
|
|
||||||
>
|
|
||||||
{#if creatingTest}
|
|
||||||
<span
|
|
||||||
class="spinner-border spinner-border-sm me-2"
|
|
||||||
role="status"
|
|
||||||
></span>
|
|
||||||
{:else}
|
|
||||||
<i class="bi bi-plus-lg me-1"></i>
|
|
||||||
{/if}
|
|
||||||
New Test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div
|
|
||||||
class="spinner-border text-primary"
|
|
||||||
role="status"
|
|
||||||
style="width: 3rem; height: 3rem;"
|
|
||||||
>
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 text-muted">Loading tests...</p>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="alert alert-warning text-center" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{:else if tests.length === 0}
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i
|
|
||||||
class="bi bi-inbox display-1 text-muted mb-3 d-block"
|
|
||||||
></i>
|
|
||||||
<h2 class="h4 text-muted mb-3">No tests yet</h2>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
Send a test email to get your first deliverability
|
|
||||||
report.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
class="btn btn-primary btn-lg"
|
|
||||||
onclick={createTest}
|
|
||||||
disabled={creatingTest}
|
|
||||||
>
|
|
||||||
<i class="bi bi-envelope-plus me-2"></i>
|
|
||||||
Start Your First Test
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<HistoryTable {tests} />
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<nav class="mt-4 d-flex justify-content-between align-items-center">
|
|
||||||
<small class="text-muted">
|
|
||||||
Showing {offset + 1}-{Math.min(
|
|
||||||
offset + limit,
|
|
||||||
total,
|
|
||||||
)} of {total} tests
|
|
||||||
</small>
|
|
||||||
<ul class="pagination mb-0">
|
|
||||||
<li
|
|
||||||
class="page-item"
|
|
||||||
class:disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="page-link"
|
|
||||||
onclick={() =>
|
|
||||||
goToPage(
|
|
||||||
Math.max(0, offset - limit),
|
|
||||||
)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
<i class="bi bi-chevron-left"></i>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="page-item disabled">
|
|
||||||
<span class="page-link">
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="page-item"
|
|
||||||
class:disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="page-link"
|
|
||||||
onclick={() =>
|
|
||||||
goToPage(offset + limit)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<i class="bi bi-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue