Compare commits
2 commits
a2b214f12f
...
f6b9c974e1
| Author | SHA1 | Date | |
|---|---|---|---|
| f6b9c974e1 | |||
| 396c51974a |
48 changed files with 1879 additions and 1786 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -26,5 +26,5 @@ logs/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# OpenAPI generated files
|
# OpenAPI generated files
|
||||||
internal/api/models.gen.go
|
internal/api/server.gen.go
|
||||||
internal/api/server.gen.go
|
internal/model/types.gen.go
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package: api
|
package: model
|
||||||
generate:
|
generate:
|
||||||
models: true
|
models: true
|
||||||
embedded-spec: false
|
embedded-spec: true
|
||||||
output: internal/api/models.gen.go
|
output: internal/model/types.gen.go
|
||||||
|
output-options:
|
||||||
|
skip-prune: true
|
||||||
|
import-mapping:
|
||||||
|
./schemas.yaml: "-"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package: api
|
package: api
|
||||||
generate:
|
generate:
|
||||||
gin-server: true
|
gin-server: true
|
||||||
|
models: true
|
||||||
embedded-spec: true
|
embedded-spec: true
|
||||||
output: internal/api/server.gen.go
|
output: internal/api/server.gen.go
|
||||||
|
import-mapping:
|
||||||
|
./schemas.yaml: git.happydns.org/happyDeliver/internal/model
|
||||||
|
|
|
||||||
1163
api/openapi.yaml
1163
api/openapi.yaml
File diff suppressed because it is too large
Load diff
1173
api/schemas.yaml
Normal file
1173
api/schemas.yaml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -21,5 +21,5 @@
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml
|
//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml
|
||||||
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml
|
//go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -50,7 +50,7 @@ require (
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 // indirect
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import (
|
||||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/config"
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
"git.happydns.org/happyDeliver/internal/storage"
|
"git.happydns.org/happyDeliver/internal/storage"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
"git.happydns.org/happyDeliver/internal/version"
|
"git.happydns.org/happyDeliver/internal/version"
|
||||||
|
|
@ -40,8 +41,8 @@ import (
|
||||||
// This interface breaks the circular dependency with pkg/analyzer
|
// This interface breaks the circular dependency with pkg/analyzer
|
||||||
type EmailAnalyzer interface {
|
type EmailAnalyzer interface {
|
||||||
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error)
|
||||||
AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string)
|
AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string)
|
||||||
CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error)
|
CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIHandler implements the ServerInterface for handling API requests
|
// APIHandler implements the ServerInterface for handling API requests
|
||||||
|
|
@ -79,11 +80,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Return response
|
// Return response
|
||||||
c.JSON(http.StatusCreated, TestResponse{
|
c.JSON(http.StatusCreated, model.TestResponse{
|
||||||
Id: base32ID,
|
Id: base32ID,
|
||||||
Email: openapi_types.Email(email),
|
Email: openapi_types.Email(email),
|
||||||
Status: TestResponseStatusPending,
|
Status: model.TestResponseStatusPending,
|
||||||
Message: stringPtr("Send your test email to the given address"),
|
Message: utils.PtrTo("Send your test email to the given address"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,10 +94,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
|
||||||
// Convert base32 ID to UUID
|
// Convert base32 ID to UUID
|
||||||
testUUID, err := utils.Base32ToUUID(id)
|
testUUID, err := utils.Base32ToUUID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, model.Error{
|
||||||
Error: "invalid_id",
|
Error: "invalid_id",
|
||||||
Message: "Invalid test ID format",
|
Message: "Invalid test ID format",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -104,20 +105,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
|
||||||
// Check if a report exists for this test ID
|
// Check if a report exists for this test ID
|
||||||
reportExists, err := h.storage.ReportExists(testUUID)
|
reportExists, err := h.storage.ReportExists(testUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
c.JSON(http.StatusInternalServerError, model.Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to check test status",
|
Message: "Failed to check test status",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status based on report existence
|
// Determine status based on report existence
|
||||||
var apiStatus TestStatus
|
var apiStatus model.TestStatus
|
||||||
if reportExists {
|
if reportExists {
|
||||||
apiStatus = TestStatusAnalyzed
|
apiStatus = model.TestStatusAnalyzed
|
||||||
} else {
|
} else {
|
||||||
apiStatus = TestStatusPending
|
apiStatus = model.TestStatusPending
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate test email address using Base32-encoded UUID
|
// Generate test email address using Base32-encoded UUID
|
||||||
|
|
@ -127,7 +128,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) {
|
||||||
h.config.Email.Domain,
|
h.config.Email.Domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, Test{
|
c.JSON(http.StatusOK, model.Test{
|
||||||
Id: id,
|
Id: id,
|
||||||
Email: openapi_types.Email(email),
|
Email: openapi_types.Email(email),
|
||||||
Status: apiStatus,
|
Status: apiStatus,
|
||||||
|
|
@ -140,10 +141,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
|
||||||
// Convert base32 ID to UUID
|
// Convert base32 ID to UUID
|
||||||
testUUID, err := utils.Base32ToUUID(id)
|
testUUID, err := utils.Base32ToUUID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, model.Error{
|
||||||
Error: "invalid_id",
|
Error: "invalid_id",
|
||||||
Message: "Invalid test ID format",
|
Message: "Invalid test ID format",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -151,16 +152,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) {
|
||||||
reportJSON, _, err := h.storage.GetReport(testUUID)
|
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, model.Error{
|
||||||
Error: "not_found",
|
Error: "not_found",
|
||||||
Message: "Report not found",
|
Message: "Report not found",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
c.JSON(http.StatusInternalServerError, model.Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to retrieve report",
|
Message: "Failed to retrieve report",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -175,10 +176,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
|
||||||
// Convert base32 ID to UUID
|
// Convert base32 ID to UUID
|
||||||
testUUID, err := utils.Base32ToUUID(id)
|
testUUID, err := utils.Base32ToUUID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, model.Error{
|
||||||
Error: "invalid_id",
|
Error: "invalid_id",
|
||||||
Message: "Invalid test ID format",
|
Message: "Invalid test ID format",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -186,16 +187,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) {
|
||||||
_, rawEmail, err := h.storage.GetReport(testUUID)
|
_, 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, model.Error{
|
||||||
Error: "not_found",
|
Error: "not_found",
|
||||||
Message: "Email not found",
|
Message: "Email not found",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
c.JSON(http.StatusInternalServerError, model.Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to retrieve raw email",
|
Message: "Failed to retrieve raw email",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -209,10 +210,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
|
||||||
// Convert base32 ID to UUID
|
// Convert base32 ID to UUID
|
||||||
testUUID, err := utils.Base32ToUUID(id)
|
testUUID, err := utils.Base32ToUUID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, model.Error{
|
||||||
Error: "invalid_id",
|
Error: "invalid_id",
|
||||||
Message: "Invalid test ID format",
|
Message: "Invalid test ID format",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -221,16 +222,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
|
||||||
_, rawEmail, err := h.storage.GetReport(testUUID)
|
_, 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, model.Error{
|
||||||
Error: "not_found",
|
Error: "not_found",
|
||||||
Message: "Email not found",
|
Message: "Email not found",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
c.JSON(http.StatusInternalServerError, model.Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to retrieve email",
|
Message: "Failed to retrieve email",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -238,20 +239,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) {
|
||||||
// Re-analyze the email using the current analyzer
|
// Re-analyze the email using the current analyzer
|
||||||
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
|
reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
c.JSON(http.StatusInternalServerError, model.Error{
|
||||||
Error: "analysis_error",
|
Error: "analysis_error",
|
||||||
Message: "Failed to re-analyze email",
|
Message: "Failed to re-analyze email",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the report in storage
|
// Update the report in storage
|
||||||
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
|
if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
c.JSON(http.StatusInternalServerError, model.Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to update report",
|
Message: "Failed to update report",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -267,24 +268,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
|
||||||
uptime := int(time.Since(h.startTime).Seconds())
|
uptime := int(time.Since(h.startTime).Seconds())
|
||||||
|
|
||||||
// Check database connectivity by trying to check if a report exists
|
// Check database connectivity by trying to check if a report exists
|
||||||
dbStatus := StatusComponentsDatabaseUp
|
dbStatus := model.StatusComponentsDatabaseUp
|
||||||
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
|
if _, err := h.storage.ReportExists(uuid.New()); err != nil {
|
||||||
dbStatus = StatusComponentsDatabaseDown
|
dbStatus = model.StatusComponentsDatabaseDown
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine overall status
|
// Determine overall status
|
||||||
overallStatus := Healthy
|
overallStatus := model.Healthy
|
||||||
if dbStatus == StatusComponentsDatabaseDown {
|
if dbStatus == model.StatusComponentsDatabaseDown {
|
||||||
overallStatus = Unhealthy
|
overallStatus = model.Unhealthy
|
||||||
}
|
}
|
||||||
|
|
||||||
mtaStatus := StatusComponentsMtaUp
|
mtaStatus := model.StatusComponentsMtaUp
|
||||||
c.JSON(http.StatusOK, Status{
|
c.JSON(http.StatusOK, model.Status{
|
||||||
Status: overallStatus,
|
Status: overallStatus,
|
||||||
Version: version.Version,
|
Version: version.Version,
|
||||||
Components: &struct {
|
Components: &struct {
|
||||||
Database *StatusComponentsDatabase `json:"database,omitempty"`
|
Database *model.StatusComponentsDatabase `json:"database,omitempty"`
|
||||||
Mta *StatusComponentsMta `json:"mta,omitempty"`
|
Mta *model.StatusComponentsMta `json:"mta,omitempty"`
|
||||||
}{
|
}{
|
||||||
Database: &dbStatus,
|
Database: &dbStatus,
|
||||||
Mta: &mtaStatus,
|
Mta: &mtaStatus,
|
||||||
|
|
@ -296,14 +297,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) {
|
||||||
// TestDomain performs synchronous domain analysis
|
// TestDomain performs synchronous domain analysis
|
||||||
// (POST /domain)
|
// (POST /domain)
|
||||||
func (h *APIHandler) TestDomain(c *gin.Context) {
|
func (h *APIHandler) TestDomain(c *gin.Context) {
|
||||||
var request DomainTestRequest
|
var request model.DomainTestRequest
|
||||||
|
|
||||||
// Bind and validate request
|
// Bind and validate request
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, model.Error{
|
||||||
Error: "invalid_request",
|
Error: "invalid_request",
|
||||||
Message: "Invalid request body",
|
Message: "Invalid request body",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -312,28 +313,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
|
||||||
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
|
dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain)
|
||||||
|
|
||||||
// Convert grade string to DomainTestResponseGrade enum
|
// Convert grade string to DomainTestResponseGrade enum
|
||||||
var responseGrade DomainTestResponseGrade
|
var responseGrade model.DomainTestResponseGrade
|
||||||
switch grade {
|
switch grade {
|
||||||
case "A+":
|
case "A+":
|
||||||
responseGrade = DomainTestResponseGradeA
|
responseGrade = model.DomainTestResponseGradeA
|
||||||
case "A":
|
case "A":
|
||||||
responseGrade = DomainTestResponseGradeA1
|
responseGrade = model.DomainTestResponseGradeA1
|
||||||
case "B":
|
case "B":
|
||||||
responseGrade = DomainTestResponseGradeB
|
responseGrade = model.DomainTestResponseGradeB
|
||||||
case "C":
|
case "C":
|
||||||
responseGrade = DomainTestResponseGradeC
|
responseGrade = model.DomainTestResponseGradeC
|
||||||
case "D":
|
case "D":
|
||||||
responseGrade = DomainTestResponseGradeD
|
responseGrade = model.DomainTestResponseGradeD
|
||||||
case "E":
|
case "E":
|
||||||
responseGrade = DomainTestResponseGradeE
|
responseGrade = model.DomainTestResponseGradeE
|
||||||
case "F":
|
case "F":
|
||||||
responseGrade = DomainTestResponseGradeF
|
responseGrade = model.DomainTestResponseGradeF
|
||||||
default:
|
default:
|
||||||
responseGrade = DomainTestResponseGradeF
|
responseGrade = model.DomainTestResponseGradeF
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
response := DomainTestResponse{
|
response := model.DomainTestResponse{
|
||||||
Domain: request.Domain,
|
Domain: request.Domain,
|
||||||
Score: score,
|
Score: score,
|
||||||
Grade: responseGrade,
|
Grade: responseGrade,
|
||||||
|
|
@ -346,14 +347,14 @@ func (h *APIHandler) TestDomain(c *gin.Context) {
|
||||||
// CheckBlacklist checks an IP address against DNS blacklists
|
// CheckBlacklist checks an IP address against DNS blacklists
|
||||||
// (POST /blacklist)
|
// (POST /blacklist)
|
||||||
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
var request BlacklistCheckRequest
|
var request model.BlacklistCheckRequest
|
||||||
|
|
||||||
// Bind and validate request
|
// Bind and validate request
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, model.Error{
|
||||||
Error: "invalid_request",
|
Error: "invalid_request",
|
||||||
Message: "Invalid request body",
|
Message: "Invalid request body",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -361,22 +362,22 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
// Perform blacklist check using analyzer
|
// Perform blacklist check using analyzer
|
||||||
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, Error{
|
c.JSON(http.StatusBadRequest, model.Error{
|
||||||
Error: "invalid_ip",
|
Error: "invalid_ip",
|
||||||
Message: "Invalid IP address",
|
Message: "Invalid IP address",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
response := BlacklistCheckResponse{
|
response := model.BlacklistCheckResponse{
|
||||||
Ip: request.Ip,
|
Ip: request.Ip,
|
||||||
Blacklists: checks,
|
Blacklists: checks,
|
||||||
Whitelists: &whitelists,
|
Whitelists: &whitelists,
|
||||||
ListedCount: listedCount,
|
ListedCount: listedCount,
|
||||||
Score: score,
|
Score: score,
|
||||||
Grade: BlacklistCheckResponseGrade(grade),
|
Grade: model.BlacklistCheckResponseGrade(grade),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
|
|
@ -386,7 +387,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) {
|
||||||
// (GET /tests)
|
// (GET /tests)
|
||||||
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
|
func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
|
||||||
if h.config.DisableTestList {
|
if h.config.DisableTestList {
|
||||||
c.JSON(http.StatusForbidden, Error{
|
c.JSON(http.StatusForbidden, model.Error{
|
||||||
Error: "feature_disabled",
|
Error: "feature_disabled",
|
||||||
Message: "Test listing is disabled on this instance",
|
Message: "Test listing is disabled on this instance",
|
||||||
})
|
})
|
||||||
|
|
@ -405,51 +406,17 @@ func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
summaries, total, err := h.storage.ListReportSummaries(offset, limit)
|
tests, total, err := h.storage.ListReportSummaries(offset, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, Error{
|
c.JSON(http.StatusInternalServerError, model.Error{
|
||||||
Error: "internal_error",
|
Error: "internal_error",
|
||||||
Message: "Failed to list tests",
|
Message: "Failed to list tests",
|
||||||
Details: stringPtr(err.Error()),
|
Details: utils.PtrTo(err.Error()),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := make([]TestSummary, 0, len(summaries))
|
c.JSON(http.StatusOK, model.TestListResponse{
|
||||||
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,
|
Tests: tests,
|
||||||
Total: int(total),
|
Total: int(total),
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ import (
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -45,21 +48,12 @@ 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)
|
ListReportSummaries(offset, limit int) ([]model.TestSummary, 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,15 +143,24 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) {
|
||||||
return result.RowsAffected, nil
|
return result.RowsAffected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary
|
||||||
|
type reportSummaryRow struct {
|
||||||
|
TestID uuid.UUID
|
||||||
|
Score int
|
||||||
|
Grade string
|
||||||
|
FromDomain string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// ListReportSummaries returns a paginated list of lightweight report summaries
|
// ListReportSummaries returns a paginated list of lightweight report summaries
|
||||||
func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) {
|
func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
|
if err := s.db.Model(&Report{}).Count(&total).Error; err != nil {
|
||||||
return nil, 0, fmt.Errorf("failed to count reports: %w", err)
|
return nil, 0, fmt.Errorf("failed to count reports: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return []ReportSummary{}, 0, nil
|
return []model.TestSummary{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectExpr string
|
var selectExpr string
|
||||||
|
|
@ -168,25 +171,41 @@ func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int
|
||||||
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
|
`convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` +
|
||||||
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
|
`convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` +
|
||||||
`created_at`
|
`created_at`
|
||||||
default: // sqlite
|
case "sqlite":
|
||||||
selectExpr = `test_id, ` +
|
selectExpr = `test_id, ` +
|
||||||
`json_extract(report_json, '$.score') as score, ` +
|
`json_extract(report_json, '$.score') as score, ` +
|
||||||
`json_extract(report_json, '$.grade') as grade, ` +
|
`json_extract(report_json, '$.grade') as grade, ` +
|
||||||
`json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
|
`json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` +
|
||||||
`created_at`
|
`created_at`
|
||||||
|
default:
|
||||||
|
return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect")
|
||||||
}
|
}
|
||||||
|
|
||||||
var summaries []ReportSummary
|
var rows []reportSummaryRow
|
||||||
err := s.db.Model(&Report{}).
|
err := s.db.Model(&Report{}).
|
||||||
Select(selectExpr).
|
Select(selectExpr).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Scan(&summaries).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
|
return nil, 0, fmt.Errorf("failed to list report summaries: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summaries := make([]model.TestSummary, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
s := model.TestSummary{
|
||||||
|
TestId: utils.UUIDToBase32(r.TestID),
|
||||||
|
Score: r.Score,
|
||||||
|
Grade: model.TestSummaryGrade(r.Grade),
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
}
|
||||||
|
if r.FromDomain != "" {
|
||||||
|
s.FromDomain = utils.PtrTo(r.FromDomain)
|
||||||
|
}
|
||||||
|
summaries = append(summaries, s)
|
||||||
|
}
|
||||||
|
|
||||||
return summaries, total, nil
|
return summaries, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// This file is part of the happyDeliver (R) project.
|
// This file is part of the happyDeliver (R) project.
|
||||||
// Copyright (c) 2025 happyDomain
|
// Copyright (c) 2026 happyDomain
|
||||||
// Authors: Pierre-Olivier Mercier, et al.
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
//
|
//
|
||||||
// This program is offered under a commercial and under the AGPL license.
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
|
@ -19,11 +19,7 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
package api
|
package utils
|
||||||
|
|
||||||
func stringPtr(s string) *string {
|
|
||||||
return &s
|
|
||||||
}
|
|
||||||
|
|
||||||
// PtrTo returns a pointer to the provided value
|
// PtrTo returns a pointer to the provided value
|
||||||
func PtrTo[T any](v T) *T {
|
func PtrTo[T any](v T) *T {
|
||||||
|
|
@ -28,7 +28,7 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
"git.happydns.org/happyDeliver/internal/config"
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||||
type AnalysisResult struct {
|
type AnalysisResult struct {
|
||||||
Email *EmailMessage
|
Email *EmailMessage
|
||||||
Results *AnalysisResults
|
Results *AnalysisResults
|
||||||
Report *api.Report
|
Report *model.Report
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeEmailBytes performs complete email analysis from raw bytes
|
// AnalyzeEmailBytes performs complete email analysis from raw bytes
|
||||||
|
|
@ -113,7 +113,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeDomain performs DNS analysis for a domain and returns the results
|
// AnalyzeDomain performs DNS analysis for a domain and returns the results
|
||||||
func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) {
|
func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) {
|
||||||
// Perform DNS analysis
|
// Perform DNS analysis
|
||||||
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
|
dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain)
|
||||||
|
|
||||||
|
|
@ -124,7 +124,7 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
|
// CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists
|
||||||
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) {
|
func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) {
|
||||||
// Check the IP against all configured RBLs
|
// Check the IP against all configured RBLs
|
||||||
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -134,7 +134,7 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.Bl
|
||||||
// Calculate score using the existing function
|
// Calculate score using the existing function
|
||||||
// Create a minimal RBLResults structure for scoring
|
// Create a minimal RBLResults structure for scoring
|
||||||
results := &DNSListResults{
|
results := &DNSListResults{
|
||||||
Checks: map[string][]api.BlacklistCheck{ip: checks},
|
Checks: map[string][]model.BlacklistCheck{ip: checks},
|
||||||
IPsChecked: []string{ip},
|
IPsChecked: []string{ip},
|
||||||
ListedCount: listedCount,
|
ListedCount: listedCount,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthenticationAnalyzer analyzes email authentication results
|
// AuthenticationAnalyzer analyzes email authentication results
|
||||||
|
|
@ -38,8 +38,8 @@ func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
||||||
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
|
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults {
|
||||||
results := &api.AuthenticationResults{}
|
results := &model.AuthenticationResults{}
|
||||||
|
|
||||||
// Parse Authentication-Results headers
|
// Parse Authentication-Results headers
|
||||||
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
|
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
|
||||||
|
|
@ -65,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
||||||
|
|
||||||
// parseAuthenticationResultsHeader parses an Authentication-Results header
|
// parseAuthenticationResultsHeader parses an Authentication-Results header
|
||||||
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
|
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
|
||||||
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
|
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) {
|
||||||
// Split by semicolon to get individual results
|
// Split by semicolon to get individual results
|
||||||
parts := strings.Split(header, ";")
|
parts := strings.Split(header, ";")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
|
|
@ -91,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
dkimResult := a.parseDKIMResult(part)
|
dkimResult := a.parseDKIMResult(part)
|
||||||
if dkimResult != nil {
|
if dkimResult != nil {
|
||||||
if results.Dkim == nil {
|
if results.Dkim == nil {
|
||||||
dkimList := []api.AuthResult{*dkimResult}
|
dkimList := []model.AuthResult{*dkimResult}
|
||||||
results.Dkim = &dkimList
|
results.Dkim = &dkimList
|
||||||
} else {
|
} else {
|
||||||
*results.Dkim = append(*results.Dkim, *dkimResult)
|
*results.Dkim = append(*results.Dkim, *dkimResult)
|
||||||
|
|
@ -145,7 +145,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
|
|
||||||
// CalculateAuthenticationScore calculates the authentication score from auth results
|
// CalculateAuthenticationScore calculates the authentication score from auth results
|
||||||
// Returns a score from 0-100 where higher is better
|
// Returns a score from 0-100 where higher is better
|
||||||
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) {
|
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) {
|
||||||
if results == nil {
|
if results == nil {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// textprotoCanonical converts a header name to canonical form
|
// textprotoCanonical converts a header name to canonical form
|
||||||
|
|
@ -52,24 +53,24 @@ func pluralize(count int) string {
|
||||||
|
|
||||||
// parseARCResult parses ARC result from Authentication-Results
|
// parseARCResult parses ARC result from Authentication-Results
|
||||||
// Example: arc=pass
|
// Example: arc=pass
|
||||||
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult {
|
||||||
result := &api.ARCResult{}
|
result := &model.ARCResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, none)
|
// Extract result (pass, fail, none)
|
||||||
re := regexp.MustCompile(`arc=(\w+)`)
|
re := regexp.MustCompile(`arc=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = api.ARCResultResult(resultStr)
|
result.Result = model.ARCResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
|
result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseARCHeaders parses ARC headers from email message
|
// parseARCHeaders parses ARC headers from email message
|
||||||
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
||||||
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
|
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult {
|
||||||
// Get all ARC-related headers
|
// Get all ARC-related headers
|
||||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
||||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
||||||
|
|
@ -80,8 +81,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &api.ARCResult{
|
result := &model.ARCResult{
|
||||||
Result: api.ARCResultResultNone,
|
Result: model.ARCResultResultNone,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count the ARC chain length (number of sets)
|
// Count the ARC chain length (number of sets)
|
||||||
|
|
@ -94,15 +95,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
|
||||||
|
|
||||||
// Determine overall result
|
// Determine overall result
|
||||||
if chainLength == 0 {
|
if chainLength == 0 {
|
||||||
result.Result = api.ARCResultResultNone
|
result.Result = model.ARCResultResultNone
|
||||||
details := "No ARC chain present"
|
details := "No ARC chain present"
|
||||||
result.Details = &details
|
result.Details = &details
|
||||||
} else if !chainValid {
|
} else if !chainValid {
|
||||||
result.Result = api.ARCResultResultFail
|
result.Result = model.ARCResultResultFail
|
||||||
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
||||||
result.Details = &details
|
result.Details = &details
|
||||||
} else {
|
} else {
|
||||||
result.Result = api.ARCResultResultPass
|
result.Result = model.ARCResultResultPass
|
||||||
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
||||||
result.Details = &details
|
result.Details = &details
|
||||||
}
|
}
|
||||||
|
|
@ -111,7 +112,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe
|
||||||
}
|
}
|
||||||
|
|
||||||
// enhanceARCResult enhances an existing ARC result with chain information
|
// enhanceARCResult enhances an existing ARC result with chain information
|
||||||
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
|
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) {
|
||||||
if arcResult == nil {
|
if arcResult == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,29 +24,29 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseARCResult(t *testing.T) {
|
func TestParseARCResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult api.ARCResultResult
|
expectedResult model.ARCResultResult
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "ARC pass",
|
name: "ARC pass",
|
||||||
part: "arc=pass",
|
part: "arc=pass",
|
||||||
expectedResult: api.ARCResultResultPass,
|
expectedResult: model.ARCResultResultPass,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ARC fail",
|
name: "ARC fail",
|
||||||
part: "arc=fail",
|
part: "arc=fail",
|
||||||
expectedResult: api.ARCResultResultFail,
|
expectedResult: model.ARCResultResultFail,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ARC none",
|
name: "ARC none",
|
||||||
part: "arc=none",
|
part: "arc=none",
|
||||||
expectedResult: api.ARCResultResultNone,
|
expectedResult: model.ARCResultResultNone,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,20 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseBIMIResult parses BIMI result from Authentication-Results
|
// parseBIMIResult parses BIMI result from Authentication-Results
|
||||||
// Example: bimi=pass header.d=example.com header.selector=default
|
// Example: bimi=pass header.d=example.com header.selector=default
|
||||||
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult {
|
||||||
result := &api.AuthResult{}
|
result := &model.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`bimi=(\w+)`)
|
re := regexp.MustCompile(`bimi=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = api.AuthResultResult(resultStr)
|
result.Result = model.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (header.d or d)
|
// Extract domain (header.d or d)
|
||||||
|
|
@ -54,17 +55,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
||||||
result.Selector = &selector
|
result.Selector = &selector
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
|
result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) {
|
||||||
if results.Bimi != nil {
|
if results.Bimi != nil {
|
||||||
switch results.Bimi.Result {
|
switch results.Bimi.Result {
|
||||||
case api.AuthResultResultPass:
|
case model.AuthResultResultPass:
|
||||||
return 100
|
return 100
|
||||||
case api.AuthResultResultDeclined:
|
case model.AuthResultResultDeclined:
|
||||||
return 59
|
return 59
|
||||||
default: // fail
|
default: // fail
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -24,42 +24,42 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseBIMIResult(t *testing.T) {
|
func TestParseBIMIResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult api.AuthResultResult
|
expectedResult model.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
expectedSelector string
|
expectedSelector string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "BIMI pass with domain and selector",
|
name: "BIMI pass with domain and selector",
|
||||||
part: "bimi=pass header.d=example.com header.selector=default",
|
part: "bimi=pass header.d=example.com header.selector=default",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "default",
|
expectedSelector: "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI fail",
|
name: "BIMI fail",
|
||||||
part: "bimi=fail header.d=example.com header.selector=default",
|
part: "bimi=fail header.d=example.com header.selector=default",
|
||||||
expectedResult: api.AuthResultResultFail,
|
expectedResult: model.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "default",
|
expectedSelector: "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI with short form (d= and selector=)",
|
name: "BIMI with short form (d= and selector=)",
|
||||||
part: "bimi=pass d=example.com selector=v1",
|
part: "bimi=pass d=example.com selector=v1",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "v1",
|
expectedSelector: "v1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI none",
|
name: "BIMI none",
|
||||||
part: "bimi=none header.d=example.com",
|
part: "bimi=none header.d=example.com",
|
||||||
expectedResult: api.AuthResultResultNone,
|
expectedResult: model.AuthResultResultNone,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,20 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseDKIMResult parses DKIM result from Authentication-Results
|
// parseDKIMResult parses DKIM result from Authentication-Results
|
||||||
// Example: dkim=pass header.d=example.com header.s=selector1
|
// Example: dkim=pass header.d=example.com header.s=selector1
|
||||||
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult {
|
||||||
result := &api.AuthResult{}
|
result := &model.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`dkim=(\w+)`)
|
re := regexp.MustCompile(`dkim=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = api.AuthResultResult(resultStr)
|
result.Result = model.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (header.d or d)
|
// Extract domain (header.d or d)
|
||||||
|
|
@ -54,18 +55,18 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
|
||||||
result.Selector = &selector
|
result.Selector = &selector
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
|
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) {
|
||||||
// Expect at least one passing signature
|
// Expect at least one passing signature
|
||||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||||
hasPass := false
|
hasPass := false
|
||||||
hasNonPass := false
|
hasNonPass := false
|
||||||
for _, dkim := range *results.Dkim {
|
for _, dkim := range *results.Dkim {
|
||||||
if dkim.Result == api.AuthResultResultPass {
|
if dkim.Result == model.AuthResultResultPass {
|
||||||
hasPass = true
|
hasPass = true
|
||||||
} else {
|
} else {
|
||||||
hasNonPass = true
|
hasNonPass = true
|
||||||
|
|
|
||||||
|
|
@ -24,35 +24,35 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseDKIMResult(t *testing.T) {
|
func TestParseDKIMResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult api.AuthResultResult
|
expectedResult model.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
expectedSelector string
|
expectedSelector string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "DKIM pass with domain and selector",
|
name: "DKIM pass with domain and selector",
|
||||||
part: "dkim=pass header.d=example.com header.s=default",
|
part: "dkim=pass header.d=example.com header.s=default",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "default",
|
expectedSelector: "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DKIM fail",
|
name: "DKIM fail",
|
||||||
part: "dkim=fail header.d=example.com header.s=selector1",
|
part: "dkim=fail header.d=example.com header.s=selector1",
|
||||||
expectedResult: api.AuthResultResultFail,
|
expectedResult: model.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "selector1",
|
expectedSelector: "selector1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DKIM with short form (d= and s=)",
|
name: "DKIM with short form (d= and s=)",
|
||||||
part: "dkim=pass d=example.com s=default",
|
part: "dkim=pass d=example.com s=default",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
expectedSelector: "default",
|
expectedSelector: "default",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,20 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseDMARCResult parses DMARC result from Authentication-Results
|
// parseDMARCResult parses DMARC result from Authentication-Results
|
||||||
// Example: dmarc=pass action=none header.from=example.com
|
// Example: dmarc=pass action=none header.from=example.com
|
||||||
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult {
|
||||||
result := &api.AuthResult{}
|
result := &model.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`dmarc=(\w+)`)
|
re := regexp.MustCompile(`dmarc=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = api.AuthResultResult(resultStr)
|
result.Result = model.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (header.from)
|
// Extract domain (header.from)
|
||||||
|
|
@ -47,17 +48,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
|
||||||
result.Domain = &domain
|
result.Domain = &domain
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
|
result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) {
|
||||||
if results.Dmarc != nil {
|
if results.Dmarc != nil {
|
||||||
switch results.Dmarc.Result {
|
switch results.Dmarc.Result {
|
||||||
case api.AuthResultResultPass:
|
case model.AuthResultResultPass:
|
||||||
return 100
|
return 100
|
||||||
case api.AuthResultResultNone:
|
case model.AuthResultResultNone:
|
||||||
return 33
|
return 33
|
||||||
default: // fail
|
default: // fail
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -24,26 +24,26 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseDMARCResult(t *testing.T) {
|
func TestParseDMARCResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult api.AuthResultResult
|
expectedResult model.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "DMARC pass",
|
name: "DMARC pass",
|
||||||
part: "dmarc=pass action=none header.from=example.com",
|
part: "dmarc=pass action=none header.from=example.com",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DMARC fail",
|
name: "DMARC fail",
|
||||||
part: "dmarc=fail action=quarantine header.from=example.com",
|
part: "dmarc=fail action=quarantine header.from=example.com",
|
||||||
expectedResult: api.AuthResultResultFail,
|
expectedResult: model.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,20 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
|
// parseIPRevResult parses IP reverse lookup result from Authentication-Results
|
||||||
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
|
// Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)
|
||||||
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult {
|
func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult {
|
||||||
result := &api.IPRevResult{}
|
result := &model.IPRevResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, temperror, permerror, none)
|
// Extract result (pass, fail, temperror, permerror, none)
|
||||||
re := regexp.MustCompile(`iprev=(\w+)`)
|
re := regexp.MustCompile(`iprev=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = api.IPRevResultResult(resultStr)
|
result.Result = model.IPRevResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract IP address (smtp.remote-ip or remote-ip)
|
// Extract IP address (smtp.remote-ip or remote-ip)
|
||||||
|
|
@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult
|
||||||
result.Hostname = &hostname
|
result.Hostname = &hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev="))
|
result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) {
|
||||||
if results.Iprev != nil {
|
if results.Iprev != nil {
|
||||||
switch results.Iprev.Result {
|
switch results.Iprev.Result {
|
||||||
case api.Pass:
|
case model.Pass:
|
||||||
return 100
|
return 100
|
||||||
default: // fail, temperror, permerror
|
default: // fail, temperror, permerror
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -24,71 +24,72 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseIPRevResult(t *testing.T) {
|
func TestParseIPRevResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult api.IPRevResultResult
|
expectedResult model.IPRevResultResult
|
||||||
expectedIP *string
|
expectedIP *string
|
||||||
expectedHostname *string
|
expectedHostname *string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "IPRev pass with IP and hostname",
|
name: "IPRev pass with IP and hostname",
|
||||||
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
||||||
expectedResult: api.Pass,
|
expectedResult: model.Pass,
|
||||||
expectedIP: api.PtrTo("195.110.101.58"),
|
expectedIP: utils.PtrTo("195.110.101.58"),
|
||||||
expectedHostname: api.PtrTo("authsmtp74.register.it"),
|
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev pass without smtp prefix",
|
name: "IPRev pass without smtp prefix",
|
||||||
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
|
part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)",
|
||||||
expectedResult: api.Pass,
|
expectedResult: model.Pass,
|
||||||
expectedIP: api.PtrTo("192.0.2.1"),
|
expectedIP: utils.PtrTo("192.0.2.1"),
|
||||||
expectedHostname: api.PtrTo("mail.example.com"),
|
expectedHostname: utils.PtrTo("mail.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev fail",
|
name: "IPRev fail",
|
||||||
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
|
part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)",
|
||||||
expectedResult: api.Fail,
|
expectedResult: model.Fail,
|
||||||
expectedIP: api.PtrTo("198.51.100.42"),
|
expectedIP: utils.PtrTo("198.51.100.42"),
|
||||||
expectedHostname: api.PtrTo("unknown.host.com"),
|
expectedHostname: utils.PtrTo("unknown.host.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev temperror",
|
name: "IPRev temperror",
|
||||||
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
|
part: "iprev=temperror smtp.remote-ip=203.0.113.1",
|
||||||
expectedResult: api.Temperror,
|
expectedResult: model.Temperror,
|
||||||
expectedIP: api.PtrTo("203.0.113.1"),
|
expectedIP: utils.PtrTo("203.0.113.1"),
|
||||||
expectedHostname: nil,
|
expectedHostname: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev permerror",
|
name: "IPRev permerror",
|
||||||
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
|
part: "iprev=permerror smtp.remote-ip=192.0.2.100",
|
||||||
expectedResult: api.Permerror,
|
expectedResult: model.Permerror,
|
||||||
expectedIP: api.PtrTo("192.0.2.100"),
|
expectedIP: utils.PtrTo("192.0.2.100"),
|
||||||
expectedHostname: nil,
|
expectedHostname: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev with IPv6",
|
name: "IPRev with IPv6",
|
||||||
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
|
part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)",
|
||||||
expectedResult: api.Pass,
|
expectedResult: model.Pass,
|
||||||
expectedIP: api.PtrTo("2001:db8::1"),
|
expectedIP: utils.PtrTo("2001:db8::1"),
|
||||||
expectedHostname: api.PtrTo("ipv6.example.com"),
|
expectedHostname: utils.PtrTo("ipv6.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev with subdomain hostname",
|
name: "IPRev with subdomain hostname",
|
||||||
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
|
part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)",
|
||||||
expectedResult: api.Pass,
|
expectedResult: model.Pass,
|
||||||
expectedIP: api.PtrTo("192.0.2.50"),
|
expectedIP: utils.PtrTo("192.0.2.50"),
|
||||||
expectedHostname: api.PtrTo("mail.subdomain.example.com"),
|
expectedHostname: utils.PtrTo("mail.subdomain.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev pass without parentheses",
|
name: "IPRev pass without parentheses",
|
||||||
part: "iprev=pass smtp.remote-ip=192.0.2.200",
|
part: "iprev=pass smtp.remote-ip=192.0.2.200",
|
||||||
expectedResult: api.Pass,
|
expectedResult: model.Pass,
|
||||||
expectedIP: api.PtrTo("192.0.2.200"),
|
expectedIP: utils.PtrTo("192.0.2.200"),
|
||||||
expectedHostname: nil,
|
expectedHostname: nil,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -142,29 +143,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
header string
|
header string
|
||||||
expectedIPRevResult *api.IPRevResultResult
|
expectedIPRevResult *model.IPRevResultResult
|
||||||
expectedIP *string
|
expectedIP *string
|
||||||
expectedHostname *string
|
expectedHostname *string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "IPRev pass in Authentication-Results",
|
name: "IPRev pass in Authentication-Results",
|
||||||
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)",
|
||||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
expectedIPRevResult: utils.PtrTo(model.Pass),
|
||||||
expectedIP: api.PtrTo("195.110.101.58"),
|
expectedIP: utils.PtrTo("195.110.101.58"),
|
||||||
expectedHostname: api.PtrTo("authsmtp74.register.it"),
|
expectedHostname: utils.PtrTo("authsmtp74.register.it"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev with other authentication methods",
|
name: "IPRev with other authentication methods",
|
||||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
|
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com",
|
||||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
expectedIPRevResult: utils.PtrTo(model.Pass),
|
||||||
expectedIP: api.PtrTo("192.0.2.1"),
|
expectedIP: utils.PtrTo("192.0.2.1"),
|
||||||
expectedHostname: api.PtrTo("mail.example.com"),
|
expectedHostname: utils.PtrTo("mail.example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IPRev fail",
|
name: "IPRev fail",
|
||||||
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
|
header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42",
|
||||||
expectedIPRevResult: api.PtrTo(api.Fail),
|
expectedIPRevResult: utils.PtrTo(model.Fail),
|
||||||
expectedIP: api.PtrTo("198.51.100.42"),
|
expectedIP: utils.PtrTo("198.51.100.42"),
|
||||||
expectedHostname: nil,
|
expectedHostname: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -175,9 +176,9 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Multiple IPRev results - only first is parsed",
|
name: "Multiple IPRev results - only first is parsed",
|
||||||
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
|
header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)",
|
||||||
expectedIPRevResult: api.PtrTo(api.Pass),
|
expectedIPRevResult: utils.PtrTo(model.Pass),
|
||||||
expectedIP: api.PtrTo("192.0.2.1"),
|
expectedIP: utils.PtrTo("192.0.2.1"),
|
||||||
expectedHostname: api.PtrTo("first.com"),
|
expectedHostname: utils.PtrTo("first.com"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +186,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
results := &api.AuthenticationResults{}
|
results := &model.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
||||||
|
|
||||||
// Check IPRev
|
// Check IPRev
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,20 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseSPFResult parses SPF result from Authentication-Results
|
// parseSPFResult parses SPF result from Authentication-Results
|
||||||
// Example: spf=pass smtp.mailfrom=sender@example.com
|
// Example: spf=pass smtp.mailfrom=sender@example.com
|
||||||
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult {
|
||||||
result := &api.AuthResult{}
|
result := &model.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`spf=(\w+)`)
|
re := regexp.MustCompile(`spf=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = api.AuthResultResult(resultStr)
|
result.Result = model.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain
|
// Extract domain
|
||||||
|
|
@ -51,13 +52,13 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "spf="))
|
result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
||||||
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult {
|
||||||
receivedSPF := email.Header.Get("Received-SPF")
|
receivedSPF := email.Header.Get("Received-SPF")
|
||||||
if receivedSPF == "" {
|
if receivedSPF == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -73,13 +74,13 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &api.AuthResult{}
|
result := &model.AuthResult{}
|
||||||
|
|
||||||
// Extract result (first word)
|
// Extract result (first word)
|
||||||
parts := strings.Fields(receivedSPF)
|
parts := strings.Fields(receivedSPF)
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
resultStr := strings.ToLower(parts[0])
|
resultStr := strings.ToLower(parts[0])
|
||||||
result.Result = api.AuthResultResult(resultStr)
|
result.Result = model.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = &receivedSPF
|
result.Details = &receivedSPF
|
||||||
|
|
@ -97,14 +98,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) {
|
||||||
if results.Spf != nil {
|
if results.Spf != nil {
|
||||||
switch results.Spf.Result {
|
switch results.Spf.Result {
|
||||||
case api.AuthResultResultPass:
|
case model.AuthResultResultPass:
|
||||||
return 100
|
return 100
|
||||||
case api.AuthResultResultNeutral, api.AuthResultResultNone:
|
case model.AuthResultResultNeutral, model.AuthResultResultNone:
|
||||||
return 50
|
return 50
|
||||||
case api.AuthResultResultSoftfail:
|
case model.AuthResultResultSoftfail:
|
||||||
return 17
|
return 17
|
||||||
default: // fail, temperror, permerror
|
default: // fail, temperror, permerror
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -24,38 +24,39 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSPFResult(t *testing.T) {
|
func TestParseSPFResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult api.AuthResultResult
|
expectedResult model.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SPF pass with domain",
|
name: "SPF pass with domain",
|
||||||
part: "spf=pass smtp.mailfrom=sender@example.com",
|
part: "spf=pass smtp.mailfrom=sender@example.com",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail",
|
name: "SPF fail",
|
||||||
part: "spf=fail smtp.mailfrom=sender@example.com",
|
part: "spf=fail smtp.mailfrom=sender@example.com",
|
||||||
expectedResult: api.AuthResultResultFail,
|
expectedResult: model.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF neutral",
|
name: "SPF neutral",
|
||||||
part: "spf=neutral smtp.mailfrom=sender@example.com",
|
part: "spf=neutral smtp.mailfrom=sender@example.com",
|
||||||
expectedResult: api.AuthResultResultNeutral,
|
expectedResult: model.AuthResultResultNeutral,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
part: "spf=softfail smtp.mailfrom=sender@example.com",
|
part: "spf=softfail smtp.mailfrom=sender@example.com",
|
||||||
expectedResult: api.AuthResultResultSoftfail,
|
expectedResult: model.AuthResultResultSoftfail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +85,7 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
receivedSPF string
|
receivedSPF string
|
||||||
expectedResult api.AuthResultResult
|
expectedResult model.AuthResultResult
|
||||||
expectedDomain *string
|
expectedDomain *string
|
||||||
expectNil bool
|
expectNil bool
|
||||||
}{
|
}{
|
||||||
|
|
@ -97,8 +98,8 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
envelope-from="user@example.com";
|
envelope-from="user@example.com";
|
||||||
helo=smtp.example.com;
|
helo=smtp.example.com;
|
||||||
client-ip=192.0.2.10`,
|
client-ip=192.0.2.10`,
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: api.PtrTo("example.com"),
|
expectedDomain: utils.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail with sender",
|
name: "SPF fail with sender",
|
||||||
|
|
@ -109,43 +110,43 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
sender="sender@test.com";
|
sender="sender@test.com";
|
||||||
helo=smtp.test.com;
|
helo=smtp.test.com;
|
||||||
client-ip=192.0.2.20`,
|
client-ip=192.0.2.20`,
|
||||||
expectedResult: api.AuthResultResultFail,
|
expectedResult: model.AuthResultResultFail,
|
||||||
expectedDomain: api.PtrTo("test.com"),
|
expectedDomain: utils.PtrTo("test.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
|
receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"",
|
||||||
expectedResult: api.AuthResultResultSoftfail,
|
expectedResult: model.AuthResultResultSoftfail,
|
||||||
expectedDomain: api.PtrTo("example.org"),
|
expectedDomain: utils.PtrTo("example.org"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF neutral",
|
name: "SPF neutral",
|
||||||
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
|
receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"",
|
||||||
expectedResult: api.AuthResultResultNeutral,
|
expectedResult: model.AuthResultResultNeutral,
|
||||||
expectedDomain: api.PtrTo("domain.net"),
|
expectedDomain: utils.PtrTo("domain.net"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF none",
|
name: "SPF none",
|
||||||
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
|
receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"",
|
||||||
expectedResult: api.AuthResultResultNone,
|
expectedResult: model.AuthResultResultNone,
|
||||||
expectedDomain: api.PtrTo("company.io"),
|
expectedDomain: utils.PtrTo("company.io"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF temperror",
|
name: "SPF temperror",
|
||||||
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
|
receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"",
|
||||||
expectedResult: api.AuthResultResultTemperror,
|
expectedResult: model.AuthResultResultTemperror,
|
||||||
expectedDomain: api.PtrTo("shop.example"),
|
expectedDomain: utils.PtrTo("shop.example"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF permerror",
|
name: "SPF permerror",
|
||||||
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
|
receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"",
|
||||||
expectedResult: api.AuthResultResultPermerror,
|
expectedResult: model.AuthResultResultPermerror,
|
||||||
expectedDomain: api.PtrTo("invalid.test"),
|
expectedDomain: utils.PtrTo("invalid.test"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF pass without domain extraction",
|
name: "SPF pass without domain extraction",
|
||||||
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
|
receivedSPF: "pass (example.com: 192.0.2.50 is authorized)",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: nil,
|
expectedDomain: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -156,8 +157,8 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "SPF with unquoted envelope-from",
|
name: "SPF with unquoted envelope-from",
|
||||||
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
|
receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: api.PtrTo("mail.example.net"),
|
expectedDomain: utils.PtrTo("mail.example.net"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,76 +24,77 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetAuthenticationScore(t *testing.T) {
|
func TestGetAuthenticationScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
results *api.AuthenticationResults
|
results *model.AuthenticationResults
|
||||||
expectedScore int
|
expectedScore int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Perfect authentication (SPF + DKIM + DMARC)",
|
name: "Perfect authentication (SPF + DKIM + DMARC)",
|
||||||
results: &api.AuthenticationResults{
|
results: &model.AuthenticationResults{
|
||||||
Spf: &api.AuthResult{
|
Spf: &model.AuthResult{
|
||||||
Result: api.AuthResultResultPass,
|
Result: model.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
Dkim: &[]api.AuthResult{
|
Dkim: &[]model.AuthResult{
|
||||||
{Result: api.AuthResultResultPass},
|
{Result: model.AuthResultResultPass},
|
||||||
},
|
},
|
||||||
Dmarc: &api.AuthResult{
|
Dmarc: &model.AuthResult{
|
||||||
Result: api.AuthResultResultPass,
|
Result: model.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
|
expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF and DKIM only",
|
name: "SPF and DKIM only",
|
||||||
results: &api.AuthenticationResults{
|
results: &model.AuthenticationResults{
|
||||||
Spf: &api.AuthResult{
|
Spf: &model.AuthResult{
|
||||||
Result: api.AuthResultResultPass,
|
Result: model.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
Dkim: &[]api.AuthResult{
|
Dkim: &[]model.AuthResult{
|
||||||
{Result: api.AuthResultResultPass},
|
{Result: model.AuthResultResultPass},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 48, // SPF=25 + DKIM=23
|
expectedScore: 48, // SPF=25 + DKIM=23
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail, DKIM pass",
|
name: "SPF fail, DKIM pass",
|
||||||
results: &api.AuthenticationResults{
|
results: &model.AuthenticationResults{
|
||||||
Spf: &api.AuthResult{
|
Spf: &model.AuthResult{
|
||||||
Result: api.AuthResultResultFail,
|
Result: model.AuthResultResultFail,
|
||||||
},
|
},
|
||||||
Dkim: &[]api.AuthResult{
|
Dkim: &[]model.AuthResult{
|
||||||
{Result: api.AuthResultResultPass},
|
{Result: model.AuthResultResultPass},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 23, // SPF=0 + DKIM=23
|
expectedScore: 23, // SPF=0 + DKIM=23
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
results: &api.AuthenticationResults{
|
results: &model.AuthenticationResults{
|
||||||
Spf: &api.AuthResult{
|
Spf: &model.AuthResult{
|
||||||
Result: api.AuthResultResultSoftfail,
|
Result: model.AuthResultResultSoftfail,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 4,
|
expectedScore: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No authentication",
|
name: "No authentication",
|
||||||
results: &api.AuthenticationResults{},
|
results: &model.AuthenticationResults{},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI adds to score",
|
name: "BIMI adds to score",
|
||||||
results: &api.AuthenticationResults{
|
results: &model.AuthenticationResults{
|
||||||
Spf: &api.AuthResult{
|
Spf: &model.AuthResult{
|
||||||
Result: api.AuthResultResultPass,
|
Result: model.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
Bimi: &api.AuthResult{
|
Bimi: &model.AuthResult{
|
||||||
Result: api.AuthResultResultPass,
|
Result: model.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectedScore: 35, // SPF (25) + BIMI (10)
|
expectedScore: 35, // SPF (25) + BIMI (10)
|
||||||
|
|
@ -117,30 +118,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
header string
|
header string
|
||||||
expectedSPFResult *api.AuthResultResult
|
expectedSPFResult *model.AuthResultResult
|
||||||
expectedSPFDomain *string
|
expectedSPFDomain *string
|
||||||
expectedDKIMCount int
|
expectedDKIMCount int
|
||||||
expectedDKIMResult *api.AuthResultResult
|
expectedDKIMResult *model.AuthResultResult
|
||||||
expectedDMARCResult *api.AuthResultResult
|
expectedDMARCResult *model.AuthResultResult
|
||||||
expectedDMARCDomain *string
|
expectedDMARCDomain *string
|
||||||
expectedBIMIResult *api.AuthResultResult
|
expectedBIMIResult *model.AuthResultResult
|
||||||
expectedARCResult *api.ARCResultResult
|
expectedARCResult *model.ARCResultResult
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Complete authentication results",
|
name: "Complete authentication results",
|
||||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com",
|
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedSPFDomain: api.PtrTo("example.com"),
|
expectedSPFDomain: utils.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedDMARCDomain: api.PtrTo("example.com"),
|
expectedDMARCDomain: utils.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF only",
|
name: "SPF only",
|
||||||
header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
|
header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedSPFDomain: api.PtrTo("domain.com"),
|
expectedSPFDomain: utils.PtrTo("domain.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedDMARCResult: nil,
|
expectedDMARCResult: nil,
|
||||||
},
|
},
|
||||||
|
|
@ -149,68 +150,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
|
header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1",
|
||||||
expectedSPFResult: nil,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Multiple DKIM signatures",
|
name: "Multiple DKIM signatures",
|
||||||
header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2",
|
header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2",
|
||||||
expectedSPFResult: nil,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 2,
|
expectedDKIMCount: 2,
|
||||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedDMARCResult: nil,
|
expectedDMARCResult: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF fail with DKIM pass",
|
name: "SPF fail with DKIM pass",
|
||||||
header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default",
|
header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultFail),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultFail),
|
||||||
expectedSPFDomain: api.PtrTo("example.com"),
|
expectedSPFDomain: utils.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedDMARCResult: nil,
|
expectedDMARCResult: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF softfail",
|
name: "SPF softfail",
|
||||||
header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
|
header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail),
|
||||||
expectedSPFDomain: api.PtrTo("example.com"),
|
expectedSPFDomain: utils.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedDMARCResult: nil,
|
expectedDMARCResult: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DMARC fail",
|
name: "DMARC fail",
|
||||||
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com",
|
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedDMARCResult: api.PtrTo(api.AuthResultResultFail),
|
expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail),
|
||||||
expectedDMARCDomain: api.PtrTo("example.com"),
|
expectedDMARCDomain: utils.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI pass",
|
name: "BIMI pass",
|
||||||
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default",
|
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedSPFDomain: api.PtrTo("example.com"),
|
expectedSPFDomain: utils.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
|
expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ARC pass",
|
name: "ARC pass",
|
||||||
header: "mail.example.com; arc=pass",
|
header: "mail.example.com; arc=pass",
|
||||||
expectedSPFResult: nil,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
|
expectedARCResult: utils.PtrTo(model.ARCResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "All authentication methods",
|
name: "All authentication methods",
|
||||||
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass",
|
header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedSPFDomain: api.PtrTo("example.com"),
|
expectedSPFDomain: utils.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedDMARCResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedDMARCDomain: api.PtrTo("example.com"),
|
expectedDMARCDomain: utils.PtrTo("example.com"),
|
||||||
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
|
expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
|
expectedARCResult: utils.PtrTo(model.ARCResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Empty header (authserv-id only)",
|
name: "Empty header (authserv-id only)",
|
||||||
|
|
@ -221,8 +222,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Empty parts with semicolons",
|
name: "Empty parts with semicolons",
|
||||||
header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;",
|
header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
expectedSPFDomain: api.PtrTo("example.com"),
|
expectedSPFDomain: utils.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -230,19 +231,19 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
header: "mail.example.com; dkim=pass d=example.com s=selector1",
|
header: "mail.example.com; dkim=pass d=example.com s=selector1",
|
||||||
expectedSPFResult: nil,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 1,
|
expectedDKIMCount: 1,
|
||||||
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF neutral",
|
name: "SPF neutral",
|
||||||
header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
|
header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral),
|
||||||
expectedSPFDomain: api.PtrTo("example.com"),
|
expectedSPFDomain: utils.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF none",
|
name: "SPF none",
|
||||||
header: "mail.example.com; spf=none",
|
header: "mail.example.com; spf=none",
|
||||||
expectedSPFResult: api.PtrTo(api.AuthResultResultNone),
|
expectedSPFResult: utils.PtrTo(model.AuthResultResultNone),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +252,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
results := &api.AuthenticationResults{}
|
results := &model.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
analyzer.parseAuthenticationResultsHeader(tt.header, results)
|
||||||
|
|
||||||
// Check SPF
|
// Check SPF
|
||||||
|
|
@ -357,13 +358,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
|
t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) {
|
||||||
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
|
header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com"
|
||||||
results := &api.AuthenticationResults{}
|
results := &model.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Spf == nil {
|
if results.Spf == nil {
|
||||||
t.Fatal("Expected SPF result, got nil")
|
t.Fatal("Expected SPF result, got nil")
|
||||||
}
|
}
|
||||||
if results.Spf.Result != api.AuthResultResultPass {
|
if results.Spf.Result != model.AuthResultResultPass {
|
||||||
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
|
t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result)
|
||||||
}
|
}
|
||||||
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
|
if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" {
|
||||||
|
|
@ -373,13 +374,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) {
|
t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) {
|
||||||
header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com"
|
header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com"
|
||||||
results := &api.AuthenticationResults{}
|
results := &model.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Dmarc == nil {
|
if results.Dmarc == nil {
|
||||||
t.Fatal("Expected DMARC result, got nil")
|
t.Fatal("Expected DMARC result, got nil")
|
||||||
}
|
}
|
||||||
if results.Dmarc.Result != api.AuthResultResultPass {
|
if results.Dmarc.Result != model.AuthResultResultPass {
|
||||||
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
|
t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result)
|
||||||
}
|
}
|
||||||
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
|
if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" {
|
||||||
|
|
@ -389,26 +390,26 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
|
|
||||||
t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) {
|
t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) {
|
||||||
header := "mail.example.com; arc=pass; arc=fail"
|
header := "mail.example.com; arc=pass; arc=fail"
|
||||||
results := &api.AuthenticationResults{}
|
results := &model.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Arc == nil {
|
if results.Arc == nil {
|
||||||
t.Fatal("Expected ARC result, got nil")
|
t.Fatal("Expected ARC result, got nil")
|
||||||
}
|
}
|
||||||
if results.Arc.Result != api.ARCResultResultPass {
|
if results.Arc.Result != model.ARCResultResultPass {
|
||||||
t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result)
|
t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) {
|
t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) {
|
||||||
header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com"
|
header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com"
|
||||||
results := &api.AuthenticationResults{}
|
results := &model.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Bimi == nil {
|
if results.Bimi == nil {
|
||||||
t.Fatal("Expected BIMI result, got nil")
|
t.Fatal("Expected BIMI result, got nil")
|
||||||
}
|
}
|
||||||
if results.Bimi.Result != api.AuthResultResultPass {
|
if results.Bimi.Result != model.AuthResultResultPass {
|
||||||
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
|
t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result)
|
||||||
}
|
}
|
||||||
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
|
if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" {
|
||||||
|
|
@ -419,7 +420,7 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) {
|
t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) {
|
||||||
// DKIM is special - multiple signatures should all be collected
|
// DKIM is special - multiple signatures should all be collected
|
||||||
header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2"
|
header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2"
|
||||||
results := &api.AuthenticationResults{}
|
results := &model.AuthenticationResults{}
|
||||||
analyzer.parseAuthenticationResultsHeader(header, results)
|
analyzer.parseAuthenticationResultsHeader(header, results)
|
||||||
|
|
||||||
if results.Dkim == nil {
|
if results.Dkim == nil {
|
||||||
|
|
@ -428,10 +429,10 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
if len(*results.Dkim) != 2 {
|
if len(*results.Dkim) != 2 {
|
||||||
t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim))
|
t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim))
|
||||||
}
|
}
|
||||||
if (*results.Dkim)[0].Result != api.AuthResultResultPass {
|
if (*results.Dkim)[0].Result != model.AuthResultResultPass {
|
||||||
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
|
t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result)
|
||||||
}
|
}
|
||||||
if (*results.Dkim)[1].Result != api.AuthResultResultFail {
|
if (*results.Dkim)[1].Result != model.AuthResultResultFail {
|
||||||
t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result)
|
t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -25,34 +25,35 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
|
// parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results
|
||||||
// Example: x-aligned-from=pass (Address match)
|
// Example: x-aligned-from=pass (Address match)
|
||||||
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult {
|
||||||
result := &api.AuthResult{}
|
result := &model.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
|
re := regexp.MustCompile(`x-aligned-from=([\w]+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = api.AuthResultResult(resultStr)
|
result.Result = model.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract details (everything after the result)
|
// Extract details (everything after the result)
|
||||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
|
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) {
|
||||||
if results.XAlignedFrom != nil {
|
if results.XAlignedFrom != nil {
|
||||||
switch results.XAlignedFrom.Result {
|
switch results.XAlignedFrom.Result {
|
||||||
case api.AuthResultResultPass:
|
case model.AuthResultResultPass:
|
||||||
// pass: positive contribution
|
// pass: positive contribution
|
||||||
return 100
|
return 100
|
||||||
case api.AuthResultResultFail:
|
case model.AuthResultResultFail:
|
||||||
// fail: negative contribution
|
// fail: negative contribution
|
||||||
return 0
|
return 0
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -24,44 +24,44 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseXAlignedFromResult(t *testing.T) {
|
func TestParseXAlignedFromResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult api.AuthResultResult
|
expectedResult model.AuthResultResult
|
||||||
expectedDetail string
|
expectedDetail string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "x-aligned-from pass with details",
|
name: "x-aligned-from pass with details",
|
||||||
part: "x-aligned-from=pass (Address match)",
|
part: "x-aligned-from=pass (Address match)",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDetail: "pass (Address match)",
|
expectedDetail: "pass (Address match)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-aligned-from fail with reason",
|
name: "x-aligned-from fail with reason",
|
||||||
part: "x-aligned-from=fail (Address mismatch)",
|
part: "x-aligned-from=fail (Address mismatch)",
|
||||||
expectedResult: api.AuthResultResultFail,
|
expectedResult: model.AuthResultResultFail,
|
||||||
expectedDetail: "fail (Address mismatch)",
|
expectedDetail: "fail (Address mismatch)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-aligned-from pass minimal",
|
name: "x-aligned-from pass minimal",
|
||||||
part: "x-aligned-from=pass",
|
part: "x-aligned-from=pass",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDetail: "pass",
|
expectedDetail: "pass",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-aligned-from neutral",
|
name: "x-aligned-from neutral",
|
||||||
part: "x-aligned-from=neutral (No alignment check performed)",
|
part: "x-aligned-from=neutral (No alignment check performed)",
|
||||||
expectedResult: api.AuthResultResultNeutral,
|
expectedResult: model.AuthResultResultNeutral,
|
||||||
expectedDetail: "neutral (No alignment check performed)",
|
expectedDetail: "neutral (No alignment check performed)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-aligned-from none",
|
name: "x-aligned-from none",
|
||||||
part: "x-aligned-from=none",
|
part: "x-aligned-from=none",
|
||||||
expectedResult: api.AuthResultResultNone,
|
expectedResult: model.AuthResultResultNone,
|
||||||
expectedDetail: "none",
|
expectedDetail: "none",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) {
|
||||||
func TestCalculateXAlignedFromScore(t *testing.T) {
|
func TestCalculateXAlignedFromScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
result *api.AuthResult
|
result *model.AuthResult
|
||||||
expectedScore int
|
expectedScore int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "pass result gives positive score",
|
name: "pass result gives positive score",
|
||||||
result: &api.AuthResult{
|
result: &model.AuthResult{
|
||||||
Result: api.AuthResultResultPass,
|
Result: model.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
expectedScore: 100,
|
expectedScore: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fail result gives zero score",
|
name: "fail result gives zero score",
|
||||||
result: &api.AuthResult{
|
result: &model.AuthResult{
|
||||||
Result: api.AuthResultResultFail,
|
Result: model.AuthResultResultFail,
|
||||||
},
|
},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "neutral result gives zero score",
|
name: "neutral result gives zero score",
|
||||||
result: &api.AuthResult{
|
result: &model.AuthResult{
|
||||||
Result: api.AuthResultResultNeutral,
|
Result: model.AuthResultResultNeutral,
|
||||||
},
|
},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "none result gives zero score",
|
name: "none result gives zero score",
|
||||||
result: &api.AuthResult{
|
result: &model.AuthResult{
|
||||||
Result: api.AuthResultResultNone,
|
Result: model.AuthResultResultNone,
|
||||||
},
|
},
|
||||||
expectedScore: 0,
|
expectedScore: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -130,7 +130,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
results := &api.AuthenticationResults{
|
results := &model.AuthenticationResults{
|
||||||
XAlignedFrom: tt.result,
|
XAlignedFrom: tt.result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,19 +25,20 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
|
// parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results
|
||||||
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
|
// Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6
|
||||||
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult {
|
||||||
result := &api.AuthResult{}
|
result := &model.AuthResult{}
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
// Extract result (pass, fail, etc.)
|
||||||
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
|
re := regexp.MustCompile(`x-google-dkim=(\w+)`)
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||||
resultStr := strings.ToLower(matches[1])
|
resultStr := strings.ToLower(matches[1])
|
||||||
result.Result = api.AuthResultResult(resultStr)
|
result.Result = model.AuthResultResult(resultStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain (header.d or d)
|
// Extract domain (header.d or d)
|
||||||
|
|
@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthRe
|
||||||
result.Selector = &selector
|
result.Selector = &selector
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
|
result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim="))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) {
|
func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) {
|
||||||
if results.XGoogleDkim != nil {
|
if results.XGoogleDkim != nil {
|
||||||
switch results.XGoogleDkim.Result {
|
switch results.XGoogleDkim.Result {
|
||||||
case api.AuthResultResultPass:
|
case model.AuthResultResultPass:
|
||||||
// pass: don't alter the score
|
// pass: don't alter the score
|
||||||
default: // fail
|
default: // fail
|
||||||
return -100
|
return -100
|
||||||
|
|
|
||||||
|
|
@ -24,39 +24,39 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseXGoogleDKIMResult(t *testing.T) {
|
func TestParseXGoogleDKIMResult(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
part string
|
part string
|
||||||
expectedResult api.AuthResultResult
|
expectedResult model.AuthResultResult
|
||||||
expectedDomain string
|
expectedDomain string
|
||||||
expectedSelector string
|
expectedSelector string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "x-google-dkim pass with domain",
|
name: "x-google-dkim pass with domain",
|
||||||
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
|
part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: "1e100.net",
|
expectedDomain: "1e100.net",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-google-dkim pass with short form",
|
name: "x-google-dkim pass with short form",
|
||||||
part: "x-google-dkim=pass d=gmail.com",
|
part: "x-google-dkim=pass d=gmail.com",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
expectedDomain: "gmail.com",
|
expectedDomain: "gmail.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-google-dkim fail",
|
name: "x-google-dkim fail",
|
||||||
part: "x-google-dkim=fail header.d=example.com",
|
part: "x-google-dkim=fail header.d=example.com",
|
||||||
expectedResult: api.AuthResultResultFail,
|
expectedResult: model.AuthResultResultFail,
|
||||||
expectedDomain: "example.com",
|
expectedDomain: "example.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "x-google-dkim with minimal info",
|
name: "x-google-dkim with minimal info",
|
||||||
part: "x-google-dkim=pass",
|
part: "x-google-dkim=pass",
|
||||||
expectedResult: api.AuthResultResultPass,
|
expectedResult: model.AuthResultResultPass,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -728,16 +729,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateContentAnalysis creates structured content analysis from results
|
// GenerateContentAnalysis creates structured content analysis from results
|
||||||
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis {
|
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis {
|
||||||
if results == nil {
|
if results == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
analysis := &api.ContentAnalysis{
|
analysis := &model.ContentAnalysis{
|
||||||
HasHtml: api.PtrTo(results.HTMLContent != ""),
|
HasHtml: utils.PtrTo(results.HTMLContent != ""),
|
||||||
HasPlaintext: api.PtrTo(results.TextContent != ""),
|
HasPlaintext: utils.PtrTo(results.TextContent != ""),
|
||||||
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
|
HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe),
|
||||||
UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{},
|
UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate text-to-image ratio (inverse of image-to-text)
|
// Calculate text-to-image ratio (inverse of image-to-text)
|
||||||
|
|
@ -750,16 +751,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build HTML issues
|
// Build HTML issues
|
||||||
htmlIssues := []api.ContentIssue{}
|
htmlIssues := []model.ContentIssue{}
|
||||||
|
|
||||||
// Add HTML parsing errors
|
// Add HTML parsing errors
|
||||||
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
|
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
|
||||||
for _, errMsg := range results.HTMLErrors {
|
for _, errMsg := range results.HTMLErrors {
|
||||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
htmlIssues = append(htmlIssues, model.ContentIssue{
|
||||||
Type: api.BrokenHtml,
|
Type: model.BrokenHtml,
|
||||||
Severity: api.ContentIssueSeverityHigh,
|
Severity: model.ContentIssueSeverityHigh,
|
||||||
Message: errMsg,
|
Message: errMsg,
|
||||||
Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
|
Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -773,53 +774,53 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if missingAltCount > 0 {
|
if missingAltCount > 0 {
|
||||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
htmlIssues = append(htmlIssues, model.ContentIssue{
|
||||||
Type: api.MissingAlt,
|
Type: model.MissingAlt,
|
||||||
Severity: api.ContentIssueSeverityMedium,
|
Severity: model.ContentIssueSeverityMedium,
|
||||||
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
|
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
|
||||||
Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
|
Advice: utils.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add excessive images issue
|
// Add excessive images issue
|
||||||
if results.ImageTextRatio > 10.0 {
|
if results.ImageTextRatio > 10.0 {
|
||||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
htmlIssues = append(htmlIssues, model.ContentIssue{
|
||||||
Type: api.ExcessiveImages,
|
Type: model.ExcessiveImages,
|
||||||
Severity: api.ContentIssueSeverityMedium,
|
Severity: model.ContentIssueSeverityMedium,
|
||||||
Message: "Email is excessively image-heavy",
|
Message: "Email is excessively image-heavy",
|
||||||
Advice: api.PtrTo("Reduce the number of images relative to text content"),
|
Advice: utils.PtrTo("Reduce the number of images relative to text content"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add suspicious URL issues
|
// Add suspicious URL issues
|
||||||
for _, suspURL := range results.SuspiciousURLs {
|
for _, suspURL := range results.SuspiciousURLs {
|
||||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
htmlIssues = append(htmlIssues, model.ContentIssue{
|
||||||
Type: api.SuspiciousLink,
|
Type: model.SuspiciousLink,
|
||||||
Severity: api.ContentIssueSeverityHigh,
|
Severity: model.ContentIssueSeverityHigh,
|
||||||
Message: "Suspicious URL detected",
|
Message: "Suspicious URL detected",
|
||||||
Location: &suspURL,
|
Location: &suspURL,
|
||||||
Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
|
Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add harmful HTML tag issues
|
// Add harmful HTML tag issues
|
||||||
for _, harmfulIssue := range results.HarmfullIssues {
|
for _, harmfulIssue := range results.HarmfullIssues {
|
||||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
htmlIssues = append(htmlIssues, model.ContentIssue{
|
||||||
Type: api.DangerousHtml,
|
Type: model.DangerousHtml,
|
||||||
Severity: api.ContentIssueSeverityCritical,
|
Severity: model.ContentIssueSeverityCritical,
|
||||||
Message: harmfulIssue,
|
Message: harmfulIssue,
|
||||||
Advice: api.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
|
Advice: utils.PtrTo("Remove dangerous HTML tags like <script>, <iframe>, <object>, <embed>, <applet>, <form>, and <base> from email content"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add general content issues (like external stylesheets)
|
// Add general content issues (like external stylesheets)
|
||||||
for _, contentIssue := range results.ContentIssues {
|
for _, contentIssue := range results.ContentIssues {
|
||||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
htmlIssues = append(htmlIssues, model.ContentIssue{
|
||||||
Type: api.BrokenHtml,
|
Type: model.BrokenHtml,
|
||||||
Severity: api.ContentIssueSeverityLow,
|
Severity: model.ContentIssueSeverityLow,
|
||||||
Message: contentIssue,
|
Message: contentIssue,
|
||||||
Advice: api.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
|
Advice: utils.PtrTo("Use inline CSS instead of external stylesheets for better email compatibility"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -829,31 +830,31 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
||||||
|
|
||||||
// Convert links
|
// Convert links
|
||||||
if len(results.Links) > 0 {
|
if len(results.Links) > 0 {
|
||||||
links := make([]api.LinkCheck, 0, len(results.Links))
|
links := make([]model.LinkCheck, 0, len(results.Links))
|
||||||
for _, link := range results.Links {
|
for _, link := range results.Links {
|
||||||
status := api.Valid
|
status := model.Valid
|
||||||
if link.Status >= 400 {
|
if link.Status >= 400 {
|
||||||
status = api.Broken
|
status = model.Broken
|
||||||
} else if !link.IsSafe {
|
} else if !link.IsSafe {
|
||||||
status = api.Suspicious
|
status = model.Suspicious
|
||||||
} else if link.Warning != "" {
|
} else if link.Warning != "" {
|
||||||
status = api.Timeout
|
status = model.Timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
apiLink := api.LinkCheck{
|
apiLink := model.LinkCheck{
|
||||||
Url: link.URL,
|
Url: link.URL,
|
||||||
Status: status,
|
Status: status,
|
||||||
}
|
}
|
||||||
|
|
||||||
if link.Status > 0 {
|
if link.Status > 0 {
|
||||||
apiLink.HttpCode = api.PtrTo(link.Status)
|
apiLink.HttpCode = utils.PtrTo(link.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a URL shortener
|
// Check if it's a URL shortener
|
||||||
parsedURL, err := url.Parse(link.URL)
|
parsedURL, err := url.Parse(link.URL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
|
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
|
||||||
apiLink.IsShortened = api.PtrTo(isShortened)
|
apiLink.IsShortened = utils.PtrTo(isShortened)
|
||||||
}
|
}
|
||||||
|
|
||||||
links = append(links, apiLink)
|
links = append(links, apiLink)
|
||||||
|
|
@ -863,9 +864,9 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
||||||
|
|
||||||
// Convert images
|
// Convert images
|
||||||
if len(results.Images) > 0 {
|
if len(results.Images) > 0 {
|
||||||
images := make([]api.ImageCheck, 0, len(results.Images))
|
images := make([]model.ImageCheck, 0, len(results.Images))
|
||||||
for _, img := range results.Images {
|
for _, img := range results.Images {
|
||||||
apiImg := api.ImageCheck{
|
apiImg := model.ImageCheck{
|
||||||
HasAlt: img.HasAlt,
|
HasAlt: img.HasAlt,
|
||||||
}
|
}
|
||||||
if img.Src != "" {
|
if img.Src != "" {
|
||||||
|
|
@ -875,7 +876,7 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
||||||
apiImg.AltText = &img.AltText
|
apiImg.AltText = &img.AltText
|
||||||
}
|
}
|
||||||
// Simple heuristic: tracking pixels are typically 1x1
|
// Simple heuristic: tracking pixels are typically 1x1
|
||||||
apiImg.IsTrackingPixel = api.PtrTo(false)
|
apiImg.IsTrackingPixel = utils.PtrTo(false)
|
||||||
|
|
||||||
images = append(images, apiImg)
|
images = append(images, apiImg)
|
||||||
}
|
}
|
||||||
|
|
@ -884,19 +885,19 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
|
||||||
|
|
||||||
// Unsubscribe methods
|
// Unsubscribe methods
|
||||||
if results.HasUnsubscribe {
|
if results.HasUnsubscribe {
|
||||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Link)
|
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Link)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, url := range c.listUnsubscribeURLs {
|
for _, url := range c.listUnsubscribeURLs {
|
||||||
if strings.HasPrefix(url, "mailto:") {
|
if strings.HasPrefix(url, "mailto:") {
|
||||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.Mailto)
|
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.Mailto)
|
||||||
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
|
} else if strings.HasPrefix(url, "http:") || strings.HasPrefix(url, "https:") {
|
||||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader)
|
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.ListUnsubscribeHeader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(*analysis.UnsubscribeMethods, api.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
|
if slices.Contains(*analysis.UnsubscribeMethods, model.ListUnsubscribeHeader) && c.hasOneClickUnsubscribe {
|
||||||
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, api.OneClick)
|
*analysis.UnsubscribeMethods = append(*analysis.UnsubscribeMethods, model.OneClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
return analysis
|
return analysis
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSAnalyzer analyzes DNS records for email domains
|
// DNSAnalyzer analyzes DNS records for email domains
|
||||||
|
|
@ -54,16 +54,16 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeDNS performs DNS validation for the email's domain
|
// AnalyzeDNS performs DNS validation for the email's domain
|
||||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.HeaderAnalysis) *api.DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.HeaderAnalysis) *model.DNSResults {
|
||||||
// Extract domain from From address
|
// Extract domain from From address
|
||||||
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
||||||
return &api.DNSResults{
|
return &model.DNSResults{
|
||||||
Errors: &[]string{"Unable to extract domain from email"},
|
Errors: &[]string{"Unable to extract domain from email"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fromDomain := *headersResults.DomainAlignment.FromDomain
|
fromDomain := *headersResults.DomainAlignment.FromDomain
|
||||||
|
|
||||||
results := &api.DNSResults{
|
results := &model.DNSResults{
|
||||||
FromDomain: fromDomain,
|
FromDomain: fromDomain,
|
||||||
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
|
RpDomain: headersResults.DomainAlignment.ReturnPathDomain,
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +109,7 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.Header
|
||||||
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
||||||
if dkimRecord != nil {
|
if dkimRecord != nil {
|
||||||
if results.DkimRecords == nil {
|
if results.DkimRecords == nil {
|
||||||
results.DkimRecords = new([]api.DKIMRecord)
|
results.DkimRecords = new([]model.DKIMRecord)
|
||||||
}
|
}
|
||||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||||
}
|
}
|
||||||
|
|
@ -127,8 +127,8 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.Header
|
||||||
|
|
||||||
// AnalyzeDomainOnly performs DNS validation for a domain without email context
|
// AnalyzeDomainOnly performs DNS validation for a domain without email context
|
||||||
// This is useful for checking domain configuration without sending an actual email
|
// This is useful for checking domain configuration without sending an actual email
|
||||||
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *model.DNSResults {
|
||||||
results := &api.DNSResults{
|
results := &model.DNSResults{
|
||||||
FromDomain: domain,
|
FromDomain: domain,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +150,7 @@ func (d *DNSAnalyzer) AnalyzeDomainOnly(domain string) *api.DNSResults {
|
||||||
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
|
// CalculateDomainOnlyScore calculates the DNS score for domain-only tests
|
||||||
// Returns a score from 0-100 where higher is better
|
// Returns a score from 0-100 where higher is better
|
||||||
// This version excludes PTR and DKIM checks since they require email context
|
// This version excludes PTR and DKIM checks since they require email context
|
||||||
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, string) {
|
func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *model.DNSResults) (int, string) {
|
||||||
if results == nil {
|
if results == nil {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +192,7 @@ func (d *DNSAnalyzer) CalculateDomainOnlyScore(results *api.DNSResults) (int, st
|
||||||
// CalculateDNSScore calculates the DNS score from records results
|
// CalculateDNSScore calculates the DNS score from records results
|
||||||
// Returns a score from 0-100 where higher is better
|
// Returns a score from 0-100 where higher is better
|
||||||
// senderIP is the original sender IP address used for FCrDNS verification
|
// senderIP is the original sender IP address used for FCrDNS verification
|
||||||
func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults, senderIP string) (int, string) {
|
func (d *DNSAnalyzer) CalculateDNSScore(results *model.DNSResults, senderIP string) (int, string) {
|
||||||
if results == nil {
|
if results == nil {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,12 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
|
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
|
||||||
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
|
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *model.BIMIRecord {
|
||||||
// BIMI records are at: selector._bimi.domain
|
// BIMI records are at: selector._bimi.domain
|
||||||
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
|
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
|
||||||
|
|
||||||
|
|
@ -40,20 +41,20 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &api.BIMIRecord{
|
return &model.BIMIRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup BIMI record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(txtRecords) == 0 {
|
if len(txtRecords) == 0 {
|
||||||
return &api.BIMIRecord{
|
return &model.BIMIRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("No BIMI record found"),
|
Error: utils.PtrTo("No BIMI record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,18 +67,18 @@ func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *api.BIMIRecord {
|
||||||
|
|
||||||
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
|
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
|
||||||
if !d.validateBIMI(bimiRecord) {
|
if !d.validateBIMI(bimiRecord) {
|
||||||
return &api.BIMIRecord{
|
return &model.BIMIRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Record: &bimiRecord,
|
Record: &bimiRecord,
|
||||||
LogoUrl: &logoURL,
|
LogoUrl: &logoURL,
|
||||||
VmcUrl: &vmcURL,
|
VmcUrl: &vmcURL,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("BIMI record appears malformed"),
|
Error: utils.PtrTo("BIMI record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &api.BIMIRecord{
|
return &model.BIMIRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Record: &bimiRecord,
|
Record: &bimiRecord,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header.
|
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header.
|
||||||
|
|
@ -61,8 +62,8 @@ func parseDKIMSignatures(signatures []string) []DKIMHeader {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
|
// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector
|
||||||
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord {
|
||||||
// DKIM records are at: selector._domainkey.domain
|
// DKIM records are at: selector._domainkey.domain
|
||||||
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
|
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
|
||||||
|
|
||||||
|
|
@ -71,20 +72,20 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &api.DKIMRecord{
|
return &model.DKIMRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(txtRecords) == 0 {
|
if len(txtRecords) == 0 {
|
||||||
return &api.DKIMRecord{
|
return &model.DKIMRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("No DKIM record found"),
|
Error: utils.PtrTo("No DKIM record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,16 +94,16 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
||||||
|
|
||||||
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
|
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
|
||||||
if !d.validateDKIM(dkimRecord) {
|
if !d.validateDKIM(dkimRecord) {
|
||||||
return &api.DKIMRecord{
|
return &model.DKIMRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Record: api.PtrTo(dkimRecord),
|
Record: utils.PtrTo(dkimRecord),
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("DKIM record appears malformed"),
|
Error: utils.PtrTo("DKIM record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &api.DKIMRecord{
|
return &model.DKIMRecord{
|
||||||
Selector: selector,
|
Selector: selector,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
Record: &dkimRecord,
|
Record: &dkimRecord,
|
||||||
|
|
@ -126,7 +127,7 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateDKIMScore(results *api.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
|
||||||
// DKIM provides strong email authentication
|
// DKIM provides strong email authentication
|
||||||
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
|
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 {
|
||||||
hasValidDKIM := false
|
hasValidDKIM := false
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,12 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkapi.DMARCRecord looks up and validates DMARC record for a domain
|
// checkmodel.DMARCRecord looks up and validates DMARC record for a domain
|
||||||
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
|
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord {
|
||||||
// DMARC records are at: _dmarc.domain
|
// DMARC records are at: _dmarc.domain
|
||||||
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
|
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
|
||||||
|
|
||||||
|
|
@ -40,9 +41,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &api.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DMARC record: %v", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,9 +57,9 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
|
||||||
}
|
}
|
||||||
|
|
||||||
if dmarcRecord == "" {
|
if dmarcRecord == "" {
|
||||||
return &api.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("No DMARC record found"),
|
Error: utils.PtrTo("No DMARC record found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,21 +78,21 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *api.DMARCRecord {
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if !d.validateDMARC(dmarcRecord) {
|
if !d.validateDMARC(dmarcRecord) {
|
||||||
return &api.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Record: &dmarcRecord,
|
Record: &dmarcRecord,
|
||||||
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
|
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||||
SubdomainPolicy: subdomainPolicy,
|
SubdomainPolicy: subdomainPolicy,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
SpfAlignment: spfAlignment,
|
SpfAlignment: spfAlignment,
|
||||||
DkimAlignment: dkimAlignment,
|
DkimAlignment: dkimAlignment,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("DMARC record appears malformed"),
|
Error: utils.PtrTo("DMARC record appears malformed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &api.DMARCRecord{
|
return &model.DMARCRecord{
|
||||||
Record: &dmarcRecord,
|
Record: &dmarcRecord,
|
||||||
Policy: api.PtrTo(api.DMARCRecordPolicy(policy)),
|
Policy: utils.PtrTo(model.DMARCRecordPolicy(policy)),
|
||||||
SubdomainPolicy: subdomainPolicy,
|
SubdomainPolicy: subdomainPolicy,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
SpfAlignment: spfAlignment,
|
SpfAlignment: spfAlignment,
|
||||||
|
|
@ -113,44 +114,44 @@ func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
|
||||||
|
|
||||||
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
|
// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record
|
||||||
// Returns "relaxed" (default) or "strict"
|
// Returns "relaxed" (default) or "strict"
|
||||||
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *api.DMARCRecordSpfAlignment {
|
func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment {
|
||||||
// Look for aspf=s (strict) or aspf=r (relaxed)
|
// Look for aspf=s (strict) or aspf=r (relaxed)
|
||||||
re := regexp.MustCompile(`aspf=(r|s)`)
|
re := regexp.MustCompile(`aspf=(r|s)`)
|
||||||
matches := re.FindStringSubmatch(record)
|
matches := re.FindStringSubmatch(record)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
if matches[1] == "s" {
|
if matches[1] == "s" {
|
||||||
return api.PtrTo(api.DMARCRecordSpfAlignmentStrict)
|
return utils.PtrTo(model.DMARCRecordSpfAlignmentStrict)
|
||||||
}
|
}
|
||||||
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
|
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
|
||||||
}
|
}
|
||||||
// Default is relaxed if not specified
|
// Default is relaxed if not specified
|
||||||
return api.PtrTo(api.DMARCRecordSpfAlignmentRelaxed)
|
return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
|
// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record
|
||||||
// Returns "relaxed" (default) or "strict"
|
// Returns "relaxed" (default) or "strict"
|
||||||
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *api.DMARCRecordDkimAlignment {
|
func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *model.DMARCRecordDkimAlignment {
|
||||||
// Look for adkim=s (strict) or adkim=r (relaxed)
|
// Look for adkim=s (strict) or adkim=r (relaxed)
|
||||||
re := regexp.MustCompile(`adkim=(r|s)`)
|
re := regexp.MustCompile(`adkim=(r|s)`)
|
||||||
matches := re.FindStringSubmatch(record)
|
matches := re.FindStringSubmatch(record)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
if matches[1] == "s" {
|
if matches[1] == "s" {
|
||||||
return api.PtrTo(api.DMARCRecordDkimAlignmentStrict)
|
return utils.PtrTo(model.DMARCRecordDkimAlignmentStrict)
|
||||||
}
|
}
|
||||||
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
|
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
|
||||||
}
|
}
|
||||||
// Default is relaxed if not specified
|
// Default is relaxed if not specified
|
||||||
return api.PtrTo(api.DMARCRecordDkimAlignmentRelaxed)
|
return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
|
// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record
|
||||||
// Returns the sp tag value or nil if not specified (defaults to main policy)
|
// Returns the sp tag value or nil if not specified (defaults to main policy)
|
||||||
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *api.DMARCRecordSubdomainPolicy {
|
func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRecordSubdomainPolicy {
|
||||||
// Look for sp=none, sp=quarantine, or sp=reject
|
// Look for sp=none, sp=quarantine, or sp=reject
|
||||||
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
|
re := regexp.MustCompile(`sp=(none|quarantine|reject)`)
|
||||||
matches := re.FindStringSubmatch(record)
|
matches := re.FindStringSubmatch(record)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
return api.PtrTo(api.DMARCRecordSubdomainPolicy(matches[1]))
|
return utils.PtrTo(model.DMARCRecordSubdomainPolicy(matches[1]))
|
||||||
}
|
}
|
||||||
// If sp is not specified, it defaults to the main policy (p tag)
|
// If sp is not specified, it defaults to the main policy (p tag)
|
||||||
// Return nil to indicate it's using the default
|
// Return nil to indicate it's using the default
|
||||||
|
|
@ -191,7 +192,7 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) {
|
||||||
// DMARC ties SPF and DKIM together and provides policy
|
// DMARC ties SPF and DKIM together and provides policy
|
||||||
if results.DmarcRecord != nil {
|
if results.DmarcRecord != nil {
|
||||||
if results.DmarcRecord.Valid {
|
if results.DmarcRecord.Valid {
|
||||||
|
|
@ -210,10 +211,10 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *api.DNSResults) (score int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Bonus points for strict alignment modes (2 points each)
|
// Bonus points for strict alignment modes (2 points each)
|
||||||
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == api.DMARCRecordSpfAlignmentStrict {
|
if results.DmarcRecord.SpfAlignment != nil && *results.DmarcRecord.SpfAlignment == model.DMARCRecordSpfAlignmentStrict {
|
||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == api.DMARCRecordDkimAlignmentStrict {
|
if results.DmarcRecord.DkimAlignment != nil && *results.DmarcRecord.DkimAlignment == model.DMARCRecordDkimAlignmentStrict {
|
||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
// Subdomain policy scoring (sp tag)
|
// Subdomain policy scoring (sp tag)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractDMARCPolicy(t *testing.T) {
|
func TestExtractDMARCPolicy(t *testing.T) {
|
||||||
|
|
@ -228,17 +229,17 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Subdomain policy - none",
|
name: "Subdomain policy - none",
|
||||||
record: "v=DMARC1; p=quarantine; sp=none",
|
record: "v=DMARC1; p=quarantine; sp=none",
|
||||||
expectedPolicy: api.PtrTo("none"),
|
expectedPolicy: utils.PtrTo("none"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Subdomain policy - quarantine",
|
name: "Subdomain policy - quarantine",
|
||||||
record: "v=DMARC1; p=reject; sp=quarantine",
|
record: "v=DMARC1; p=reject; sp=quarantine",
|
||||||
expectedPolicy: api.PtrTo("quarantine"),
|
expectedPolicy: utils.PtrTo("quarantine"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Subdomain policy - reject",
|
name: "Subdomain policy - reject",
|
||||||
record: "v=DMARC1; p=quarantine; sp=reject",
|
record: "v=DMARC1; p=quarantine; sp=reject",
|
||||||
expectedPolicy: api.PtrTo("reject"),
|
expectedPolicy: utils.PtrTo("reject"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No subdomain policy specified (defaults to main policy)",
|
name: "No subdomain policy specified (defaults to main policy)",
|
||||||
|
|
@ -248,7 +249,7 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Complex record with subdomain policy",
|
name: "Complex record with subdomain policy",
|
||||||
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
|
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100",
|
||||||
expectedPolicy: api.PtrTo("quarantine"),
|
expectedPolicy: utils.PtrTo("quarantine"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,22 +283,22 @@ func TestExtractDMARCPercentage(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Percentage - 100",
|
name: "Percentage - 100",
|
||||||
record: "v=DMARC1; p=quarantine; pct=100",
|
record: "v=DMARC1; p=quarantine; pct=100",
|
||||||
expectedPercentage: api.PtrTo(100),
|
expectedPercentage: utils.PtrTo(100),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Percentage - 50",
|
name: "Percentage - 50",
|
||||||
record: "v=DMARC1; p=quarantine; pct=50",
|
record: "v=DMARC1; p=quarantine; pct=50",
|
||||||
expectedPercentage: api.PtrTo(50),
|
expectedPercentage: utils.PtrTo(50),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Percentage - 25",
|
name: "Percentage - 25",
|
||||||
record: "v=DMARC1; p=reject; pct=25",
|
record: "v=DMARC1; p=reject; pct=25",
|
||||||
expectedPercentage: api.PtrTo(25),
|
expectedPercentage: utils.PtrTo(25),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Percentage - 0",
|
name: "Percentage - 0",
|
||||||
record: "v=DMARC1; p=none; pct=0",
|
record: "v=DMARC1; p=none; pct=0",
|
||||||
expectedPercentage: api.PtrTo(0),
|
expectedPercentage: utils.PtrTo(0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "No percentage specified (defaults to 100)",
|
name: "No percentage specified (defaults to 100)",
|
||||||
|
|
@ -307,7 +308,7 @@ func TestExtractDMARCPercentage(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "Complex record with percentage",
|
name: "Complex record with percentage",
|
||||||
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
|
record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75",
|
||||||
expectedPercentage: api.PtrTo(75),
|
expectedPercentage: utils.PtrTo(75),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Invalid percentage > 100 (ignored)",
|
name: "Invalid percentage > 100 (ignored)",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
|
// checkPTRAndForward performs reverse DNS lookup (PTR) and forward confirmation (A/AAAA)
|
||||||
|
|
@ -63,7 +63,7 @@ func (d *DNSAnalyzer) checkPTRAndForward(ip string) ([]string, []string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
|
// Proper reverse DNS (PTR) and forward-confirmed reverse DNS (FCrDNS) is important for deliverability
|
||||||
func (d *DNSAnalyzer) calculatePTRScore(results *api.DNSResults, senderIP string) (score int) {
|
func (d *DNSAnalyzer) calculatePTRScore(results *model.DNSResults, senderIP string) (score int) {
|
||||||
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
|
if results.PtrRecords != nil && len(*results.PtrRecords) > 0 {
|
||||||
// 50 points for having PTR records
|
// 50 points for having PTR records
|
||||||
score += 50
|
score += 50
|
||||||
|
|
|
||||||
|
|
@ -25,36 +25,37 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkMXRecords looks up MX records for a domain
|
// checkMXRecords looks up MX records for a domain
|
||||||
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
|
func (d *DNSAnalyzer) checkMXRecords(domain string) *[]model.MXRecord {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
mxRecords, err := d.resolver.LookupMX(ctx, domain)
|
mxRecords, err := d.resolver.LookupMX(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &[]api.MXRecord{
|
return &[]model.MXRecord{
|
||||||
{
|
{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup MX records: %v", err)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(mxRecords) == 0 {
|
if len(mxRecords) == 0 {
|
||||||
return &[]api.MXRecord{
|
return &[]model.MXRecord{
|
||||||
{
|
{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("No MX records found"),
|
Error: utils.PtrTo("No MX records found"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []api.MXRecord
|
var results []model.MXRecord
|
||||||
for _, mx := range mxRecords {
|
for _, mx := range mxRecords {
|
||||||
results = append(results, api.MXRecord{
|
results = append(results, model.MXRecord{
|
||||||
Host: mx.Host,
|
Host: mx.Host,
|
||||||
Priority: mx.Pref,
|
Priority: mx.Pref,
|
||||||
Valid: true,
|
Valid: true,
|
||||||
|
|
@ -64,7 +65,7 @@ func (d *DNSAnalyzer) checkMXRecords(domain string) *[]api.MXRecord {
|
||||||
return &results
|
return &results
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateMXScore(results *api.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateMXScore(results *model.DNSResults) (score int) {
|
||||||
// Having valid MX records is critical for email deliverability
|
// Having valid MX records is critical for email deliverability
|
||||||
// From domain MX records (half points) - needed for replies
|
// From domain MX records (half points) - needed for replies
|
||||||
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
if results.FromMxRecords != nil && len(*results.FromMxRecords) > 0 {
|
||||||
|
|
|
||||||
|
|
@ -27,33 +27,34 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
|
// checkSPFRecords looks up and validates SPF records for a domain, including resolving include: directives
|
||||||
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]api.SPFRecord {
|
func (d *DNSAnalyzer) checkSPFRecords(domain string) *[]model.SPFRecord {
|
||||||
visited := make(map[string]bool)
|
visited := make(map[string]bool)
|
||||||
return d.resolveSPFRecords(domain, visited, 0, true)
|
return d.resolveSPFRecords(domain, visited, 0, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSPFRecords recursively resolves SPF records including include: directives
|
// resolveSPFRecords recursively resolves SPF records including include: directives
|
||||||
// isMainRecord indicates if this is the primary domain's record (not an included one)
|
// isMainRecord indicates if this is the primary domain's record (not an included one)
|
||||||
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]api.SPFRecord {
|
func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool, depth int, isMainRecord bool) *[]model.SPFRecord {
|
||||||
const maxDepth = 10 // Prevent infinite recursion
|
const maxDepth = 10 // Prevent infinite recursion
|
||||||
|
|
||||||
if depth > maxDepth {
|
if depth > maxDepth {
|
||||||
return &[]api.SPFRecord{
|
return &[]model.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("Maximum SPF include depth exceeded"),
|
Error: utils.PtrTo("Maximum SPF include depth exceeded"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent circular references
|
// Prevent circular references
|
||||||
if visited[domain] {
|
if visited[domain] {
|
||||||
return &[]api.SPFRecord{}
|
return &[]model.SPFRecord{}
|
||||||
}
|
}
|
||||||
visited[domain] = true
|
visited[domain] = true
|
||||||
|
|
||||||
|
|
@ -62,11 +63,11 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
|
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &[]api.SPFRecord{
|
return &[]model.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
|
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup TXT records: %v", err)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -82,23 +83,23 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
if spfCount == 0 {
|
if spfCount == 0 {
|
||||||
return &[]api.SPFRecord{
|
return &[]model.SPFRecord{
|
||||||
{
|
{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("No SPF record found"),
|
Error: utils.PtrTo("No SPF record found"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []api.SPFRecord
|
var results []model.SPFRecord
|
||||||
|
|
||||||
if spfCount > 1 {
|
if spfCount > 1 {
|
||||||
results = append(results, api.SPFRecord{
|
results = append(results, model.SPFRecord{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Record: &spfRecord,
|
Record: &spfRecord,
|
||||||
Valid: false,
|
Valid: false,
|
||||||
Error: api.PtrTo("Multiple SPF records found (RFC violation)"),
|
Error: utils.PtrTo("Multiple SPF records found (RFC violation)"),
|
||||||
})
|
})
|
||||||
return &results
|
return &results
|
||||||
}
|
}
|
||||||
|
|
@ -107,28 +108,28 @@ func (d *DNSAnalyzer) resolveSPFRecords(domain string, visited map[string]bool,
|
||||||
validationErr := d.validateSPF(spfRecord, isMainRecord)
|
validationErr := d.validateSPF(spfRecord, isMainRecord)
|
||||||
|
|
||||||
// Extract the "all" mechanism qualifier
|
// Extract the "all" mechanism qualifier
|
||||||
var allQualifier *api.SPFRecordAllQualifier
|
var allQualifier *model.SPFRecordAllQualifier
|
||||||
var errMsg *string
|
var errMsg *string
|
||||||
|
|
||||||
if validationErr != nil {
|
if validationErr != nil {
|
||||||
errMsg = api.PtrTo(validationErr.Error())
|
errMsg = utils.PtrTo(validationErr.Error())
|
||||||
} else {
|
} else {
|
||||||
// Extract qualifier from the "all" mechanism
|
// Extract qualifier from the "all" mechanism
|
||||||
if strings.HasSuffix(spfRecord, " -all") {
|
if strings.HasSuffix(spfRecord, " -all") {
|
||||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("-"))
|
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("-"))
|
||||||
} else if strings.HasSuffix(spfRecord, " ~all") {
|
} else if strings.HasSuffix(spfRecord, " ~all") {
|
||||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("~"))
|
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("~"))
|
||||||
} else if strings.HasSuffix(spfRecord, " +all") {
|
} else if strings.HasSuffix(spfRecord, " +all") {
|
||||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
|
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
|
||||||
} else if strings.HasSuffix(spfRecord, " ?all") {
|
} else if strings.HasSuffix(spfRecord, " ?all") {
|
||||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("?"))
|
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("?"))
|
||||||
} else if strings.HasSuffix(spfRecord, " all") {
|
} else if strings.HasSuffix(spfRecord, " all") {
|
||||||
// Implicit + qualifier (default)
|
// Implicit + qualifier (default)
|
||||||
allQualifier = api.PtrTo(api.SPFRecordAllQualifier("+"))
|
allQualifier = utils.PtrTo(model.SPFRecordAllQualifier("+"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, api.SPFRecord{
|
results = append(results, model.SPFRecord{
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
Record: &spfRecord,
|
Record: &spfRecord,
|
||||||
Valid: validationErr == nil,
|
Valid: validationErr == nil,
|
||||||
|
|
@ -301,7 +302,7 @@ func (d *DNSAnalyzer) hasSPFStrictFail(record string) bool {
|
||||||
return strings.HasSuffix(record, " -all")
|
return strings.HasSuffix(record, " -all")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DNSAnalyzer) calculateSPFScore(results *api.DNSResults) (score int) {
|
func (d *DNSAnalyzer) calculateSPFScore(results *model.DNSResults) (score int) {
|
||||||
// SPF is essential for email authentication
|
// SPF is essential for email authentication
|
||||||
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
|
if results.SpfRecords != nil && len(*results.SpfRecords) > 0 {
|
||||||
// Find the main SPF record by skipping redirects
|
// Find the main SPF record by skipping redirects
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ import (
|
||||||
|
|
||||||
"golang.org/x/net/publicsuffix"
|
"golang.org/x/net/publicsuffix"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HeaderAnalyzer analyzes email header quality and structure
|
// HeaderAnalyzer analyzes email header quality and structure
|
||||||
|
|
@ -43,7 +44,7 @@ func NewHeaderAnalyzer() *HeaderAnalyzer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateHeaderScore evaluates email structural quality from header analysis
|
// CalculateHeaderScore evaluates email structural quality from header analysis
|
||||||
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) {
|
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) {
|
||||||
if analysis == nil || analysis.Headers == nil {
|
if analysis == nil || analysis.Headers == nil {
|
||||||
return 0, ' '
|
return 0, ' '
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +188,7 @@ func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// isNoReplyAddress checks if a header check represents a no-reply email address
|
// isNoReplyAddress checks if a header check represents a no-reply email address
|
||||||
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck api.HeaderCheck) bool {
|
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool {
|
||||||
if !headerCheck.Present || headerCheck.Value == nil {
|
if !headerCheck.Present || headerCheck.Value == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -243,18 +244,18 @@ func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateHeaderAnalysis creates structured header analysis from email
|
// GenerateHeaderAnalysis creates structured header analysis from email
|
||||||
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *api.AuthenticationResults) *api.HeaderAnalysis {
|
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis {
|
||||||
if email == nil {
|
if email == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
analysis := &api.HeaderAnalysis{}
|
analysis := &model.HeaderAnalysis{}
|
||||||
|
|
||||||
// Check for proper MIME structure
|
// Check for proper MIME structure
|
||||||
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
|
analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0)
|
||||||
|
|
||||||
// Initialize headers map
|
// Initialize headers map
|
||||||
headers := make(map[string]api.HeaderCheck)
|
headers := make(map[string]model.HeaderCheck)
|
||||||
|
|
||||||
// Check required headers
|
// Check required headers
|
||||||
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
||||||
|
|
@ -308,12 +309,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkHeader checks if a header is present and valid
|
// checkHeader checks if a header is present and valid
|
||||||
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
|
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck {
|
||||||
value := email.GetHeaderValue(headerName)
|
value := email.GetHeaderValue(headerName)
|
||||||
present := email.HasHeader(headerName) && value != ""
|
present := email.HasHeader(headerName) && value != ""
|
||||||
|
|
||||||
importanceEnum := api.HeaderCheckImportance(importance)
|
importanceEnum := model.HeaderCheckImportance(importance)
|
||||||
check := &api.HeaderCheck{
|
check := &model.HeaderCheck{
|
||||||
Present: present,
|
Present: present,
|
||||||
Importance: &importanceEnum,
|
Importance: &importanceEnum,
|
||||||
}
|
}
|
||||||
|
|
@ -374,10 +375,10 @@ func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, imp
|
||||||
}
|
}
|
||||||
|
|
||||||
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
||||||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *api.AuthenticationResults) *api.DomainAlignment {
|
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *model.AuthenticationResults) *model.DomainAlignment {
|
||||||
alignment := &api.DomainAlignment{
|
alignment := &model.DomainAlignment{
|
||||||
Aligned: api.PtrTo(true),
|
Aligned: utils.PtrTo(true),
|
||||||
RelaxedAligned: api.PtrTo(true),
|
RelaxedAligned: utils.PtrTo(true),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract From domain
|
// Extract From domain
|
||||||
|
|
@ -405,13 +406,13 @@ func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract DKIM domains from authentication results
|
// Extract DKIM domains from authentication results
|
||||||
var dkimDomains []api.DKIMDomainInfo
|
var dkimDomains []model.DKIMDomainInfo
|
||||||
if authResults != nil && authResults.Dkim != nil {
|
if authResults != nil && authResults.Dkim != nil {
|
||||||
for _, dkim := range *authResults.Dkim {
|
for _, dkim := range *authResults.Dkim {
|
||||||
if dkim.Domain != nil && *dkim.Domain != "" {
|
if dkim.Domain != nil && *dkim.Domain != "" {
|
||||||
domain := *dkim.Domain
|
domain := *dkim.Domain
|
||||||
orgDomain := h.getOrganizationalDomain(domain)
|
orgDomain := h.getOrganizationalDomain(domain)
|
||||||
dkimDomains = append(dkimDomains, api.DKIMDomainInfo{
|
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
OrgDomain: orgDomain,
|
OrgDomain: orgDomain,
|
||||||
})
|
})
|
||||||
|
|
@ -560,18 +561,18 @@ func (h *HeaderAnalyzer) getOrganizationalDomain(domain string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// findHeaderIssues identifies issues with headers
|
// findHeaderIssues identifies issues with headers
|
||||||
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
|
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIssue {
|
||||||
var issues []api.HeaderIssue
|
var issues []model.HeaderIssue
|
||||||
|
|
||||||
// Check for missing required headers
|
// Check for missing required headers
|
||||||
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
||||||
for _, header := range requiredHeaders {
|
for _, header := range requiredHeaders {
|
||||||
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
||||||
issues = append(issues, api.HeaderIssue{
|
issues = append(issues, model.HeaderIssue{
|
||||||
Header: header,
|
Header: header,
|
||||||
Severity: api.HeaderIssueSeverityCritical,
|
Severity: model.HeaderIssueSeverityCritical,
|
||||||
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
||||||
Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
|
Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -579,11 +580,11 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue
|
||||||
// Check Message-ID format
|
// Check Message-ID format
|
||||||
messageID := email.GetHeaderValue("Message-ID")
|
messageID := email.GetHeaderValue("Message-ID")
|
||||||
if messageID != "" && !h.isValidMessageID(messageID) {
|
if messageID != "" && !h.isValidMessageID(messageID) {
|
||||||
issues = append(issues, api.HeaderIssue{
|
issues = append(issues, model.HeaderIssue{
|
||||||
Header: "Message-ID",
|
Header: "Message-ID",
|
||||||
Severity: api.HeaderIssueSeverityMedium,
|
Severity: model.HeaderIssueSeverityMedium,
|
||||||
Message: "Message-ID format is invalid",
|
Message: "Message-ID format is invalid",
|
||||||
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
Advice: utils.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -591,7 +592,7 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseReceivedChain extracts the chain of Received headers from an email
|
// parseReceivedChain extracts the chain of Received headers from an email
|
||||||
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop {
|
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
|
||||||
if email == nil || email.Header == nil {
|
if email == nil || email.Header == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -601,7 +602,7 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedH
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var chain []api.ReceivedHop
|
var chain []model.ReceivedHop
|
||||||
|
|
||||||
for _, receivedValue := range receivedHeaders {
|
for _, receivedValue := range receivedHeaders {
|
||||||
hop := h.parseReceivedHeader(receivedValue)
|
hop := h.parseReceivedHeader(receivedValue)
|
||||||
|
|
@ -614,8 +615,8 @@ func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedH
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseReceivedHeader parses a single Received header value
|
// parseReceivedHeader parses a single Received header value
|
||||||
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.ReceivedHop {
|
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.ReceivedHop {
|
||||||
hop := &api.ReceivedHop{}
|
hop := &model.ReceivedHop{}
|
||||||
|
|
||||||
// Normalize whitespace - Received headers can span multiple lines
|
// Normalize whitespace - Received headers can span multiple lines
|
||||||
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCalculateHeaderScore(t *testing.T) {
|
func TestCalculateHeaderScore(t *testing.T) {
|
||||||
|
|
@ -404,7 +404,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
name string
|
name string
|
||||||
receivedHeaders []string
|
receivedHeaders []string
|
||||||
expectedHops int
|
expectedHops int
|
||||||
validateFirst func(*testing.T, *EmailMessage, []api.ReceivedHop)
|
validateFirst func(*testing.T, *EmailMessage, []model.ReceivedHop)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "No Received headers",
|
name: "No Received headers",
|
||||||
|
|
@ -417,7 +417,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
|
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +450,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
|
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
|
||||||
},
|
},
|
||||||
expectedHops: 2,
|
expectedHops: 2,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
||||||
if len(hops) != 2 {
|
if len(hops) != 2 {
|
||||||
t.Fatalf("Expected 2 hops, got %d", len(hops))
|
t.Fatalf("Expected 2 hops, got %d", len(hops))
|
||||||
}
|
}
|
||||||
|
|
@ -472,7 +472,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -499,7 +499,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
|
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +527,7 @@ func TestParseReceivedChain(t *testing.T) {
|
||||||
"from unknown by localhost",
|
"from unknown by localhost",
|
||||||
},
|
},
|
||||||
expectedHops: 1,
|
expectedHops: 1,
|
||||||
validateFirst: func(t *testing.T, email *EmailMessage, hops []api.ReceivedHop) {
|
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
|
||||||
if len(hops) == 0 {
|
if len(hops) == 0 {
|
||||||
t.Fatal("Expected at least one hop")
|
t.Fatal("Expected at least one hop")
|
||||||
}
|
}
|
||||||
|
|
@ -1012,16 +1012,16 @@ func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create authentication results with DKIM signatures
|
// Create authentication results with DKIM signatures
|
||||||
var authResults *api.AuthenticationResults
|
var authResults *model.AuthenticationResults
|
||||||
if len(tt.dkimDomains) > 0 {
|
if len(tt.dkimDomains) > 0 {
|
||||||
dkimResults := make([]api.AuthResult, 0, len(tt.dkimDomains))
|
dkimResults := make([]model.AuthResult, 0, len(tt.dkimDomains))
|
||||||
for _, domain := range tt.dkimDomains {
|
for _, domain := range tt.dkimDomains {
|
||||||
dkimResults = append(dkimResults, api.AuthResult{
|
dkimResults = append(dkimResults, model.AuthResult{
|
||||||
Result: api.AuthResultResultPass,
|
Result: model.AuthResultResultPass,
|
||||||
Domain: &domain,
|
Domain: &domain,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
authResults = &api.AuthenticationResults{
|
authResults = &model.AuthenticationResults{
|
||||||
Dkim: &dkimResults,
|
Dkim: &dkimResults,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
// DNSListChecker checks IP addresses against DNS-based block/allow lists.
|
||||||
|
|
@ -117,7 +118,7 @@ func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *
|
||||||
|
|
||||||
// DNSListResults represents the results of DNS list checks
|
// DNSListResults represents the results of DNS list checks
|
||||||
type DNSListResults struct {
|
type DNSListResults struct {
|
||||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP
|
Checks map[string][]model.BlacklistCheck // Map of IP -> list of checks for that IP
|
||||||
IPsChecked []string
|
IPsChecked []string
|
||||||
ListedCount int // Total listings including informational entries
|
ListedCount int // Total listings including informational entries
|
||||||
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
RelevantListedCount int // Listings on scoring (non-informational) lists only
|
||||||
|
|
@ -126,7 +127,7 @@ type DNSListResults struct {
|
||||||
// CheckEmail checks all IPs found in the email headers against the configured lists
|
// CheckEmail checks all IPs found in the email headers against the configured lists
|
||||||
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||||
results := &DNSListResults{
|
results := &DNSListResults{
|
||||||
Checks: make(map[string][]api.BlacklistCheck),
|
Checks: make(map[string][]model.BlacklistCheck),
|
||||||
}
|
}
|
||||||
|
|
||||||
ips := r.extractIPs(email)
|
ips := r.extractIPs(email)
|
||||||
|
|
@ -157,12 +158,12 @@ func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckIP checks a single IP address against all configured lists in parallel
|
// CheckIP checks a single IP address against all configured lists in parallel
|
||||||
func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) {
|
func (r *DNSListChecker) CheckIP(ip string) ([]model.BlacklistCheck, int, error) {
|
||||||
if !r.isPublicIP(ip) {
|
if !r.isPublicIP(ip) {
|
||||||
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
checks := make([]api.BlacklistCheck, len(r.Lists))
|
checks := make([]model.BlacklistCheck, len(r.Lists))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for i, list := range r.Lists {
|
for i, list := range r.Lists {
|
||||||
|
|
@ -239,14 +240,14 @@ func (r *DNSListChecker) isPublicIP(ipStr string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkIP checks a single IP against a single DNS list
|
// checkIP checks a single IP against a single DNS list
|
||||||
func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
|
func (r *DNSListChecker) checkIP(ip, list string) model.BlacklistCheck {
|
||||||
check := api.BlacklistCheck{
|
check := model.BlacklistCheck{
|
||||||
Rbl: list,
|
Rbl: list,
|
||||||
}
|
}
|
||||||
|
|
||||||
reversedIP := r.reverseIP(ip)
|
reversedIP := r.reverseIP(ip)
|
||||||
if reversedIP == "" {
|
if reversedIP == "" {
|
||||||
check.Error = api.PtrTo("Failed to reverse IP address")
|
check.Error = utils.PtrTo("Failed to reverse IP address")
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,17 +264,17 @@ func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck {
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
check.Error = utils.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(addrs) > 0 {
|
if len(addrs) > 0 {
|
||||||
check.Response = api.PtrTo(addrs[0])
|
check.Response = utils.PtrTo(addrs[0])
|
||||||
|
|
||||||
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
// In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings.
|
||||||
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") {
|
||||||
check.Listed = false
|
check.Listed = false
|
||||||
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
check.Error = utils.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0]))
|
||||||
} else {
|
} else {
|
||||||
check.Listed = true
|
check.Listed = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewRBLChecker(t *testing.T) {
|
func TestNewRBLChecker(t *testing.T) {
|
||||||
|
|
@ -336,7 +336,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
||||||
|
|
||||||
func TestGetUniqueListedIPs(t *testing.T) {
|
func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
results := &DNSListResults{
|
results := &DNSListResults{
|
||||||
Checks: map[string][]api.BlacklistCheck{
|
Checks: map[string][]model.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
{Rbl: "bl.spamcop.net", Listed: true},
|
{Rbl: "bl.spamcop.net", Listed: true},
|
||||||
|
|
@ -364,7 +364,7 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
||||||
|
|
||||||
func TestGetRBLsForIP(t *testing.T) {
|
func TestGetRBLsForIP(t *testing.T) {
|
||||||
results := &DNSListResults{
|
results := &DNSListResults{
|
||||||
Checks: map[string][]api.BlacklistCheck{
|
Checks: map[string][]model.BlacklistCheck{
|
||||||
"198.51.100.1": {
|
"198.51.100.1": {
|
||||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||||
{Rbl: "bl.spamcop.net", Listed: true},
|
{Rbl: "bl.spamcop.net", Listed: true},
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ package analyzer
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
"git.happydns.org/happyDeliver/internal/utils"
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
@ -66,14 +66,14 @@ func NewReportGenerator(
|
||||||
// AnalysisResults contains all intermediate analysis results
|
// AnalysisResults contains all intermediate analysis results
|
||||||
type AnalysisResults struct {
|
type AnalysisResults struct {
|
||||||
Email *EmailMessage
|
Email *EmailMessage
|
||||||
Authentication *api.AuthenticationResults
|
Authentication *model.AuthenticationResults
|
||||||
Content *ContentResults
|
Content *ContentResults
|
||||||
DNS *api.DNSResults
|
DNS *model.DNSResults
|
||||||
Headers *api.HeaderAnalysis
|
Headers *model.HeaderAnalysis
|
||||||
RBL *DNSListResults
|
RBL *DNSListResults
|
||||||
DNSWL *DNSListResults
|
DNSWL *DNSListResults
|
||||||
SpamAssassin *api.SpamAssassinResult
|
SpamAssassin *model.SpamAssassinResult
|
||||||
Rspamd *api.RspamdResult
|
Rspamd *model.RspamdResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeEmail performs complete email analysis
|
// AnalyzeEmail performs complete email analysis
|
||||||
|
|
@ -96,11 +96,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateReport creates a complete API report from analysis results
|
// GenerateReport creates a complete API report from analysis results
|
||||||
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
|
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *model.Report {
|
||||||
reportID := uuid.New()
|
reportID := uuid.New()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
report := &api.Report{
|
report := &model.Report{
|
||||||
Id: utils.UUIDToBase32(reportID),
|
Id: utils.UUIDToBase32(reportID),
|
||||||
TestId: utils.UUIDToBase32(testID),
|
TestId: utils.UUIDToBase32(testID),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
|
|
@ -169,19 +169,19 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
spamGrade = MinGrade(saGrade, rspamdGrade)
|
spamGrade = MinGrade(saGrade, rspamdGrade)
|
||||||
}
|
}
|
||||||
|
|
||||||
report.Summary = &api.ScoreSummary{
|
report.Summary = &model.ScoreSummary{
|
||||||
DnsScore: dnsScore,
|
DnsScore: dnsScore,
|
||||||
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
|
DnsGrade: model.ScoreSummaryDnsGrade(dnsGrade),
|
||||||
AuthenticationScore: authScore,
|
AuthenticationScore: authScore,
|
||||||
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
|
AuthenticationGrade: model.ScoreSummaryAuthenticationGrade(authGrade),
|
||||||
BlacklistScore: blacklistScore,
|
BlacklistScore: blacklistScore,
|
||||||
BlacklistGrade: api.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
|
BlacklistGrade: model.ScoreSummaryBlacklistGrade(MinGrade(blacklistGrade, whitelistGrade)),
|
||||||
ContentScore: contentScore,
|
ContentScore: contentScore,
|
||||||
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
|
ContentGrade: model.ScoreSummaryContentGrade(contentGrade),
|
||||||
HeaderScore: headerScore,
|
HeaderScore: headerScore,
|
||||||
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
|
HeaderGrade: model.ScoreSummaryHeaderGrade(headerGrade),
|
||||||
SpamScore: spamScore,
|
SpamScore: spamScore,
|
||||||
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
|
SpamGrade: model.ScoreSummarySpamGrade(spamGrade),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add authentication results
|
// Add authentication results
|
||||||
|
|
@ -213,16 +213,16 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
|
|
||||||
// Add SpamAssassin result with individual deliverability score
|
// Add SpamAssassin result with individual deliverability score
|
||||||
if results.SpamAssassin != nil {
|
if results.SpamAssassin != nil {
|
||||||
saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade)
|
saGradeTyped := model.SpamAssassinResultDeliverabilityGrade(saGrade)
|
||||||
results.SpamAssassin.DeliverabilityScore = api.PtrTo(saScore)
|
results.SpamAssassin.DeliverabilityScore = utils.PtrTo(saScore)
|
||||||
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
|
results.SpamAssassin.DeliverabilityGrade = &saGradeTyped
|
||||||
}
|
}
|
||||||
report.Spamassassin = results.SpamAssassin
|
report.Spamassassin = results.SpamAssassin
|
||||||
|
|
||||||
// Add rspamd result with individual deliverability score
|
// Add rspamd result with individual deliverability score
|
||||||
if results.Rspamd != nil {
|
if results.Rspamd != nil {
|
||||||
rspamdGradeTyped := api.RspamdResultDeliverabilityGrade(rspamdGrade)
|
rspamdGradeTyped := model.RspamdResultDeliverabilityGrade(rspamdGrade)
|
||||||
results.Rspamd.DeliverabilityScore = api.PtrTo(rspamdScore)
|
results.Rspamd.DeliverabilityScore = utils.PtrTo(rspamdScore)
|
||||||
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
|
results.Rspamd.DeliverabilityGrade = &rspamdGradeTyped
|
||||||
}
|
}
|
||||||
report.Rspamd = results.Rspamd
|
report.Rspamd = results.Rspamd
|
||||||
|
|
@ -288,7 +288,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
||||||
}
|
}
|
||||||
|
|
||||||
if minusGrade < 255 {
|
if minusGrade < 255 {
|
||||||
report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade}))
|
report.Grade = model.ReportGrade(string([]byte{'A' + minusGrade}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default rspamd action thresholds (rspamd built-in defaults)
|
// Default rspamd action thresholds (rspamd built-in defaults)
|
||||||
|
|
@ -47,7 +47,7 @@ func NewRspamdAnalyzer(symbols map[string]string) *RspamdAnalyzer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
// AnalyzeRspamd extracts and analyzes rspamd results from email headers
|
||||||
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *model.RspamdResult {
|
||||||
headers := email.GetRspamdHeaders()
|
headers := email.GetRspamdHeaders()
|
||||||
if len(headers) == 0 {
|
if len(headers) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -60,8 +60,8 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &api.RspamdResult{
|
result := &model.RspamdResult{
|
||||||
Symbols: make(map[string]api.SpamTestDetail),
|
Symbols: make(map[string]model.SpamTestDetail),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
// Parse X-Spamd-Result header (primary source for score, threshold, and symbols)
|
||||||
|
|
@ -107,7 +107,7 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||||
|
|
||||||
// parseSpamdResult parses the X-Spamd-Result header
|
// parseSpamdResult parses the X-Spamd-Result header
|
||||||
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
// Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..."
|
||||||
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResult) {
|
func (a *RspamdAnalyzer) parseSpamdResult(header string, result *model.RspamdResult) {
|
||||||
// Extract score and threshold from the first line
|
// Extract score and threshold from the first line
|
||||||
// e.g. "default: False [-3.91 / 15.00]"
|
// e.g. "default: False [-3.91 / 15.00]"
|
||||||
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
scoreRe := regexp.MustCompile(`\[\s*(-?\d+\.?\d*)\s*/\s*(-?\d+\.?\d*)\s*\]`)
|
||||||
|
|
@ -141,7 +141,7 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
|
||||||
if len(matches) > 2 {
|
if len(matches) > 2 {
|
||||||
name := matches[1]
|
name := matches[1]
|
||||||
score, _ := strconv.ParseFloat(matches[2], 64)
|
score, _ := strconv.ParseFloat(matches[2], 64)
|
||||||
sym := api.SpamTestDetail{
|
sym := model.SpamTestDetail{
|
||||||
Name: name,
|
Name: name,
|
||||||
Score: float32(score),
|
Score: float32(score),
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +155,7 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
// CalculateRspamdScore calculates the rspamd contribution to deliverability (0-100 scale)
|
||||||
func (a *RspamdAnalyzer) CalculateRspamdScore(result *api.RspamdResult) (int, string) {
|
func (a *RspamdAnalyzer) CalculateRspamdScore(result *model.RspamdResult) (int, string) {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return 100, "" // rspamd not installed
|
return 100, "" // rspamd not installed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import (
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
||||||
|
|
@ -130,8 +130,8 @@ func TestParseSpamdResult(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := &api.RspamdResult{
|
result := &model.RspamdResult{
|
||||||
Symbols: make(map[string]api.SpamTestDetail),
|
Symbols: make(map[string]model.SpamTestDetail),
|
||||||
}
|
}
|
||||||
analyzer.parseSpamdResult(tt.header, result)
|
analyzer.parseSpamdResult(tt.header, result)
|
||||||
|
|
||||||
|
|
@ -281,7 +281,7 @@ func TestAnalyzeRspamd(t *testing.T) {
|
||||||
func TestCalculateRspamdScore(t *testing.T) {
|
func TestCalculateRspamdScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
result *api.RspamdResult
|
result *model.RspamdResult
|
||||||
expectedScore int
|
expectedScore int
|
||||||
expectedGrade string
|
expectedGrade string
|
||||||
}{
|
}{
|
||||||
|
|
@ -293,7 +293,7 @@ func TestCalculateRspamdScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score well below threshold",
|
name: "Score well below threshold",
|
||||||
result: &api.RspamdResult{
|
result: &model.RspamdResult{
|
||||||
Score: -3.91,
|
Score: -3.91,
|
||||||
Threshold: 15.00,
|
Threshold: 15.00,
|
||||||
},
|
},
|
||||||
|
|
@ -302,7 +302,7 @@ func TestCalculateRspamdScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score at zero",
|
name: "Score at zero",
|
||||||
result: &api.RspamdResult{
|
result: &model.RspamdResult{
|
||||||
Score: 0,
|
Score: 0,
|
||||||
Threshold: 15.00,
|
Threshold: 15.00,
|
||||||
},
|
},
|
||||||
|
|
@ -312,7 +312,7 @@ func TestCalculateRspamdScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score at threshold (half of 2*threshold)",
|
name: "Score at threshold (half of 2*threshold)",
|
||||||
result: &api.RspamdResult{
|
result: &model.RspamdResult{
|
||||||
Score: 15.00,
|
Score: 15.00,
|
||||||
Threshold: 15.00,
|
Threshold: 15.00,
|
||||||
},
|
},
|
||||||
|
|
@ -321,7 +321,7 @@ func TestCalculateRspamdScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score above 2*threshold",
|
name: "Score above 2*threshold",
|
||||||
result: &api.RspamdResult{
|
result: &model.RspamdResult{
|
||||||
Score: 31.00,
|
Score: 31.00,
|
||||||
Threshold: 15.00,
|
Threshold: 15.00,
|
||||||
},
|
},
|
||||||
|
|
@ -330,7 +330,7 @@ func TestCalculateRspamdScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score exactly at 2*threshold",
|
name: "Score exactly at 2*threshold",
|
||||||
result: &api.RspamdResult{
|
result: &model.RspamdResult{
|
||||||
Score: 30.00,
|
Score: 30.00,
|
||||||
Threshold: 15.00,
|
Threshold: 15.00,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
package analyzer
|
package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScoreToGrade converts a percentage score (0-100) to a letter grade
|
// ScoreToGrade converts a percentage score (0-100) to a letter grade
|
||||||
|
|
@ -65,9 +65,9 @@ func ScoreToGradeKind(score int) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
|
// ScoreToReportGrade converts a percentage score to an model.ReportGrade
|
||||||
func ScoreToReportGrade(score int) api.ReportGrade {
|
func ScoreToReportGrade(score int) model.ReportGrade {
|
||||||
return api.ReportGrade(ScoreToGrade(score))
|
return model.ReportGrade(ScoreToGrade(score))
|
||||||
}
|
}
|
||||||
|
|
||||||
// gradeRank returns a numeric rank for a grade (lower = worse)
|
// gradeRank returns a numeric rank for a grade (lower = worse)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
||||||
|
|
@ -39,7 +40,7 @@ func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
|
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
|
||||||
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.SpamAssassinResult {
|
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *model.SpamAssassinResult {
|
||||||
headers := email.GetSpamAssassinHeaders()
|
headers := email.GetSpamAssassinHeaders()
|
||||||
if len(headers) == 0 {
|
if len(headers) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -53,8 +54,8 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &api.SpamAssassinResult{
|
result := &model.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]api.SpamTestDetail),
|
TestDetails: make(map[string]model.SpamTestDetail),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spam-Status header
|
// Parse X-Spam-Status header
|
||||||
|
|
@ -76,13 +77,13 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
||||||
|
|
||||||
// Parse X-Spam-Report header for detailed test results
|
// Parse X-Spam-Report header for detailed test results
|
||||||
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
||||||
result.Report = api.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
|
result.Report = utils.PtrTo(strings.Replace(reportHeader, " * ", "\n* ", -1))
|
||||||
a.parseSpamReport(reportHeader, result)
|
a.parseSpamReport(reportHeader, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse X-Spam-Checker-Version
|
// Parse X-Spam-Checker-Version
|
||||||
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
|
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
|
||||||
result.Version = api.PtrTo(strings.TrimSpace(versionHeader))
|
result.Version = utils.PtrTo(strings.TrimSpace(versionHeader))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -90,7 +91,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
||||||
|
|
||||||
// parseSpamStatus parses the X-Spam-Status header
|
// parseSpamStatus parses the X-Spam-Status header
|
||||||
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
|
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
|
||||||
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAssassinResult) {
|
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *model.SpamAssassinResult) {
|
||||||
// Check if spam (first word)
|
// Check if spam (first word)
|
||||||
parts := strings.SplitN(header, ",", 2)
|
parts := strings.SplitN(header, ",", 2)
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
|
|
@ -134,7 +135,7 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *api.SpamAs
|
||||||
// * 0.0 TEST_NAME Description line 1
|
// * 0.0 TEST_NAME Description line 1
|
||||||
// * continuation line 2
|
// * continuation line 2
|
||||||
// * continuation line 3
|
// * continuation line 3
|
||||||
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAssassinResult) {
|
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *model.SpamAssassinResult) {
|
||||||
segments := strings.Split(report, "*")
|
segments := strings.Split(report, "*")
|
||||||
|
|
||||||
// Regex to match test lines: score TEST_NAME Description
|
// Regex to match test lines: score TEST_NAME Description
|
||||||
|
|
@ -156,7 +157,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
|
||||||
// Save previous test if exists
|
// Save previous test if exists
|
||||||
if currentTestName != "" {
|
if currentTestName != "" {
|
||||||
description := strings.TrimSpace(currentDescription.String())
|
description := strings.TrimSpace(currentDescription.String())
|
||||||
detail := api.SpamTestDetail{
|
detail := model.SpamTestDetail{
|
||||||
Name: currentTestName,
|
Name: currentTestName,
|
||||||
Score: result.TestDetails[currentTestName].Score,
|
Score: result.TestDetails[currentTestName].Score,
|
||||||
Description: &description,
|
Description: &description,
|
||||||
|
|
@ -174,7 +175,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
|
||||||
currentDescription.WriteString(description)
|
currentDescription.WriteString(description)
|
||||||
|
|
||||||
// Initialize with score
|
// Initialize with score
|
||||||
result.TestDetails[testName] = api.SpamTestDetail{
|
result.TestDetails[testName] = model.SpamTestDetail{
|
||||||
Name: testName,
|
Name: testName,
|
||||||
Score: float32(score),
|
Score: float32(score),
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +192,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
|
||||||
// Save the last test if exists
|
// Save the last test if exists
|
||||||
if currentTestName != "" {
|
if currentTestName != "" {
|
||||||
description := strings.TrimSpace(currentDescription.String())
|
description := strings.TrimSpace(currentDescription.String())
|
||||||
detail := api.SpamTestDetail{
|
detail := model.SpamTestDetail{
|
||||||
Name: currentTestName,
|
Name: currentTestName,
|
||||||
Score: result.TestDetails[currentTestName].Score,
|
Score: result.TestDetails[currentTestName].Score,
|
||||||
Description: &description,
|
Description: &description,
|
||||||
|
|
@ -201,7 +202,7 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
|
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability
|
||||||
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) {
|
func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *model.SpamAssassinResult) (int, string) {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return 100, "" // No spam scan results, assume good
|
return 100, "" // No spam scan results, assume good
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSpamStatus(t *testing.T) {
|
func TestParseSpamStatus(t *testing.T) {
|
||||||
|
|
@ -77,8 +78,8 @@ func TestParseSpamStatus(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := &api.SpamAssassinResult{
|
result := &model.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]api.SpamTestDetail),
|
TestDetails: make(map[string]model.SpamTestDetail),
|
||||||
}
|
}
|
||||||
analyzer.parseSpamStatus(tt.header, result)
|
analyzer.parseSpamStatus(tt.header, result)
|
||||||
|
|
||||||
|
|
@ -115,27 +116,27 @@ func TestParseSpamReport(t *testing.T) {
|
||||||
`
|
`
|
||||||
|
|
||||||
analyzer := NewSpamAssassinAnalyzer()
|
analyzer := NewSpamAssassinAnalyzer()
|
||||||
result := &api.SpamAssassinResult{
|
result := &model.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]api.SpamTestDetail),
|
TestDetails: make(map[string]model.SpamTestDetail),
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer.parseSpamReport(report, result)
|
analyzer.parseSpamReport(report, result)
|
||||||
|
|
||||||
expectedTests := map[string]api.SpamTestDetail{
|
expectedTests := map[string]model.SpamTestDetail{
|
||||||
"BAYES_99": {
|
"BAYES_99": {
|
||||||
Name: "BAYES_99",
|
Name: "BAYES_99",
|
||||||
Score: 5.0,
|
Score: 5.0,
|
||||||
Description: api.PtrTo("Bayes spam probability is 99 to 100%"),
|
Description: utils.PtrTo("Bayes spam probability is 99 to 100%"),
|
||||||
},
|
},
|
||||||
"SPOOFED_SENDER": {
|
"SPOOFED_SENDER": {
|
||||||
Name: "SPOOFED_SENDER",
|
Name: "SPOOFED_SENDER",
|
||||||
Score: 3.5,
|
Score: 3.5,
|
||||||
Description: api.PtrTo("From address doesn't match envelope sender"),
|
Description: utils.PtrTo("From address doesn't match envelope sender"),
|
||||||
},
|
},
|
||||||
"ALL_TRUSTED": {
|
"ALL_TRUSTED": {
|
||||||
Name: "ALL_TRUSTED",
|
Name: "ALL_TRUSTED",
|
||||||
Score: -1.0,
|
Score: -1.0,
|
||||||
Description: api.PtrTo("All mail servers are trusted"),
|
Description: utils.PtrTo("All mail servers are trusted"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,7 +158,7 @@ func TestParseSpamReport(t *testing.T) {
|
||||||
func TestGetSpamAssassinScore(t *testing.T) {
|
func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
result *api.SpamAssassinResult
|
result *model.SpamAssassinResult
|
||||||
expectedScore int
|
expectedScore int
|
||||||
minScore int
|
minScore int
|
||||||
maxScore int
|
maxScore int
|
||||||
|
|
@ -169,7 +170,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Excellent score (negative)",
|
name: "Excellent score (negative)",
|
||||||
result: &api.SpamAssassinResult{
|
result: &model.SpamAssassinResult{
|
||||||
Score: -2.5,
|
Score: -2.5,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -177,7 +178,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Good score (below threshold)",
|
name: "Good score (below threshold)",
|
||||||
result: &api.SpamAssassinResult{
|
result: &model.SpamAssassinResult{
|
||||||
Score: 2.0,
|
Score: 2.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -185,7 +186,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Score at threshold",
|
name: "Score at threshold",
|
||||||
result: &api.SpamAssassinResult{
|
result: &model.SpamAssassinResult{
|
||||||
Score: 5.0,
|
Score: 5.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -193,7 +194,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Above threshold (spam)",
|
name: "Above threshold (spam)",
|
||||||
result: &api.SpamAssassinResult{
|
result: &model.SpamAssassinResult{
|
||||||
Score: 6.0,
|
Score: 6.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -201,7 +202,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "High spam score",
|
name: "High spam score",
|
||||||
result: &api.SpamAssassinResult{
|
result: &model.SpamAssassinResult{
|
||||||
Score: 12.0,
|
Score: 12.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
@ -209,7 +210,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Very high spam score",
|
name: "Very high spam score",
|
||||||
result: &api.SpamAssassinResult{
|
result: &model.SpamAssassinResult{
|
||||||
Score: 20.0,
|
Score: 20.0,
|
||||||
RequiredScore: 5.0,
|
RequiredScore: 5.0,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue