diff --git a/api/openapi.yaml b/api/openapi.yaml index 225e26c..ee56cff 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -76,6 +76,49 @@ paths: schema: $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}: get: tags: @@ -1365,3 +1408,53 @@ components: items: $ref: '#/components/schemas/BlacklistCheck' 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 diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 470136e..e524b40 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -381,3 +381,78 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { 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, + }) +} diff --git a/internal/config/cli.go b/internal/config/cli.go index 77108ca..fcc914f 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -44,6 +44,7 @@ func declareFlags(o *Config) { 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.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 } diff --git a/internal/config/config.go b/internal/config/config.go index 9d803d0..b264994 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,7 @@ type Config struct { RateLimit uint // API rate limit (requests per second per IP) SurveyURL url.URL // URL for user feedback survey CustomLogoURL string // URL for custom logo image in the web UI + DisableTestList bool // Disable the public test listing endpoint } // DatabaseConfig contains database connection settings diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 39b2eb6..1077e74 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -45,11 +45,21 @@ type Storage interface { ReportExists(testID uuid.UUID) (bool, error) UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) + ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) // Close closes the database connection 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 type DBStorage struct { db *gorm.DB @@ -139,6 +149,47 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { 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 func (s *DBStorage) Close() error { sqlDB, err := s.db.DB() diff --git a/web/routes.go b/web/routes.go index 876954c..056115d 100644 --- a/web/routes.go +++ b/web/routes.go @@ -70,6 +70,10 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) { appConfig["custom_logo_url"] = cfg.CustomLogoURL } + if !cfg.DisableTestList { + appConfig["test_list_enabled"] = true + } + if appcfg, err := json.MarshalIndent(appConfig, "", " "); err != nil { log.Println("Unable to generate JSON config to inject in web application") } else { @@ -95,6 +99,7 @@ func DeclareRoutes(cfg *config.Config, router *gin.Engine) { router.GET("/domain/:domain", serveOrReverse("/", cfg)) router.GET("/test/", 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("/img/*path", serveOrReverse("", cfg)) diff --git a/web/src/lib/components/HistoryTable.svelte b/web/src/lib/components/HistoryTable.svelte new file mode 100644 index 0000000..737d025 --- /dev/null +++ b/web/src/lib/components/HistoryTable.svelte @@ -0,0 +1,72 @@ + + +
| Grade | +Score | +Domain | +Date | ++ |
|---|---|---|---|---|
|
+ |
+ + {test.score}% + | +
+ {#if test.from_domain}
+ {test.from_domain}
+ {:else}
+ -
+ {/if}
+ |
+ + {formatDate(test.created_at)} + | ++ + | +
Loading tests...
++ Send a test email to get your first deliverability + report. +
+ +