Compare commits
8 commits
5647df3072
...
fcdb5ecd9c
| Author | SHA1 | Date | |
|---|---|---|---|
| fcdb5ecd9c | |||
| 3c192f17fd | |||
| 35fc997390 | |||
| 2fcee1b885 | |||
| 26025c96a2 | |||
| 76ee50a100 | |||
| 71e0832416 | |||
| c96a8b92b8 |
29 changed files with 420 additions and 66 deletions
19
README.md
19
README.md
|
|
@ -166,7 +166,24 @@ The server will start on `http://localhost:8080` by default.
|
||||||
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
|
It is expected your setup annotate the email with eg. opendkim, spamassassin, rspamd, ...
|
||||||
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
happyDeliver will not perform thoses checks, it relies instead on standard software to have real world annotations.
|
||||||
|
|
||||||
Choose one of the following way to integrate happyDeliver in your existing setup:
|
#### Receiver Hostname
|
||||||
|
|
||||||
|
happyDeliver filters `Authentication-Results` headers by hostname to only trust headers added by your MTA (and not headers that may have been injected by the sender). By default, it uses the system hostname (`os.Hostname()`).
|
||||||
|
|
||||||
|
If your MTA's `authserv-id` (the hostname at the beginning of `Authentication-Results` headers) differs from the machine running happyDeliver, you must set it explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./happyDeliver server -receiver-hostname mail.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via environment variable:
|
||||||
|
```bash
|
||||||
|
HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ./happyDeliver server
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to find the correct value:** look at the `Authentication-Results` headers in a received email. They start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...` — in this case, use `mail.example.com`.
|
||||||
|
|
||||||
|
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
|
||||||
|
|
||||||
#### Postfix LMTP Transport
|
#### Postfix LMTP Transport
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,14 +110,38 @@ Default configuration for the Docker environment:
|
||||||
The container accepts these environment variables:
|
The container accepts these environment variables:
|
||||||
|
|
||||||
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
- `HAPPYDELIVER_DOMAIN`: Email domain for test addresses (default: happydeliver.local)
|
||||||
|
- `HAPPYDELIVER_RECEIVER_HOSTNAME`: Hostname used to filter `Authentication-Results` headers (see below)
|
||||||
|
- `POSTFIX_CERT_FILE` / `POSTFIX_KEY_FILE`: TLS certificate and key paths for Postfix SMTP
|
||||||
|
|
||||||
Note that the hostname of the container is used to filter the authentication tests results.
|
### Receiver Hostname
|
||||||
|
|
||||||
Example:
|
happyDeliver filters `Authentication-Results` headers by hostname to only trust results from the expected MTA. By default, it uses the system hostname (i.e., the container's `--hostname`).
|
||||||
|
|
||||||
|
In the all-in-one Docker container, the container hostname is also used as the `authserv-id` in the embedded Postfix and authentication_milter, so everything matches automatically.
|
||||||
|
|
||||||
|
**When bypassing the embedded Postfix** (e.g., routing emails from your own MTA via LMTP), your MTA's `authserv-id` will likely differ from the container hostname. In that case, set `HAPPYDELIVER_RECEIVER_HOSTNAME` to your MTA's hostname:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-e HAPPYDELIVER_DOMAIN=example.com \
|
||||||
|
-e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com \
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
To find the correct value, look at the `Authentication-Results` headers in a received email — they start with the authserv-id, e.g. `Authentication-Results: mail.example.com; spf=pass ...`.
|
||||||
|
|
||||||
|
If the value is misconfigured, happyDeliver will log a warning when the last `Received` hop doesn't match the expected hostname.
|
||||||
|
|
||||||
|
Example (all-in-one, no override needed):
|
||||||
```bash
|
```bash
|
||||||
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
docker run -e HAPPYDELIVER_DOMAIN=example.com --hostname mail.example.com ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Example (external MTA integration):
|
||||||
|
```bash
|
||||||
|
docker run -e HAPPYDELIVER_DOMAIN=example.com -e HAPPYDELIVER_RECEIVER_HOSTNAME=mail.example.com ...
|
||||||
|
```
|
||||||
|
|
||||||
## Volumes
|
## Volumes
|
||||||
|
|
||||||
**Required volumes:**
|
**Required volumes:**
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -49,7 +49,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
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -125,8 +125,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 h1:4i+F2cvwBFZeplxCssNdLy3MhNzUD87mI3HnayHZkAU=
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
github.com/oapi-codegen/oapi-codegen/v2 v2.6.0/go.mod h1:eWHeJSohQJIINJZzzQriVynfGsnlQVh0UkN2UYYcw4Q=
|
||||||
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
|
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
|
||||||
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ func declareFlags(o *Config) {
|
||||||
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails")
|
||||||
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)")
|
||||||
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
|
flag.StringVar(&o.Email.LMTPAddr, "lmtp-addr", o.Email.LMTPAddr, "LMTP server listen address")
|
||||||
|
flag.StringVar(&o.Email.ReceiverHostname, "receiver-hostname", o.Email.ReceiverHostname, "Hostname used to filter Authentication-Results headers (defaults to os.Hostname())")
|
||||||
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query")
|
||||||
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query")
|
||||||
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)")
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ import (
|
||||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func getHostname() string {
|
||||||
|
h, _ := os.Hostname()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
// Config represents the application configuration
|
// Config represents the application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DevProxy string
|
DevProxy string
|
||||||
|
|
@ -58,6 +63,7 @@ type EmailConfig struct {
|
||||||
Domain string
|
Domain string
|
||||||
TestAddressPrefix string
|
TestAddressPrefix string
|
||||||
LMTPAddr string
|
LMTPAddr string
|
||||||
|
ReceiverHostname string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalysisConfig contains timeout and behavior settings for email analysis
|
// AnalysisConfig contains timeout and behavior settings for email analysis
|
||||||
|
|
@ -84,6 +90,7 @@ func DefaultConfig() *Config {
|
||||||
Domain: "happydeliver.local",
|
Domain: "happydeliver.local",
|
||||||
TestAddressPrefix: "test-",
|
TestAddressPrefix: "test-",
|
||||||
LMTPAddr: "127.0.0.1:2525",
|
LMTPAddr: "127.0.0.1:2525",
|
||||||
|
ReceiverHostname: getHostname(),
|
||||||
},
|
},
|
||||||
Analysis: AnalysisConfig{
|
Analysis: AnalysisConfig{
|
||||||
DNSTimeout: 5 * time.Second,
|
DNSTimeout: 5 * time.Second,
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,17 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
|
||||||
|
|
||||||
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score)
|
||||||
|
|
||||||
|
// Warn if the last Received hop doesn't match the expected receiver hostname
|
||||||
|
if r.config.Email.ReceiverHostname != "" &&
|
||||||
|
result.Report.HeaderAnalysis != nil &&
|
||||||
|
result.Report.HeaderAnalysis.ReceivedChain != nil &&
|
||||||
|
len(*result.Report.HeaderAnalysis.ReceivedChain) > 0 {
|
||||||
|
lastHop := (*result.Report.HeaderAnalysis.ReceivedChain)[0]
|
||||||
|
if lastHop.By != nil && *lastHop.By != r.config.Email.ReceiverHostname {
|
||||||
|
log.Printf("WARNING: Last Received hop 'by' field (%s) does not match expected receiver hostname (%s): check your RECEIVER_HOSTNAME config as authentication results will be false", *lastHop.By, r.config.Email.ReceiverHostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Marshal report to JSON
|
// Marshal report to JSON
|
||||||
reportJSON, err := json.Marshal(result.Report)
|
reportJSON, err := json.Marshal(result.Report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type EmailAnalyzer struct {
|
||||||
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
||||||
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||||
generator := NewReportGenerator(
|
generator := NewReportGenerator(
|
||||||
|
cfg.Email.ReceiverHostname,
|
||||||
cfg.Analysis.DNSTimeout,
|
cfg.Analysis.DNSTimeout,
|
||||||
cfg.Analysis.HTTPTimeout,
|
cfg.Analysis.HTTPTimeout,
|
||||||
cfg.Analysis.RBLs,
|
cfg.Analysis.RBLs,
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthenticationAnalyzer analyzes email authentication results
|
// AuthenticationAnalyzer analyzes email authentication results
|
||||||
type AuthenticationAnalyzer struct{}
|
type AuthenticationAnalyzer struct {
|
||||||
|
receiverHostname string
|
||||||
|
}
|
||||||
|
|
||||||
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
||||||
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
|
func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
|
||||||
return &AuthenticationAnalyzer{}
|
return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
||||||
|
|
@ -40,7 +42,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
||||||
results := &api.AuthenticationResults{}
|
results := &api.AuthenticationResults{}
|
||||||
|
|
||||||
// Parse Authentication-Results headers
|
// Parse Authentication-Results headers
|
||||||
authHeaders := email.GetAuthenticationResults()
|
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
|
||||||
for _, header := range authHeaders {
|
for _, header := range authHeaders {
|
||||||
a.parseAuthenticationResultsHeader(header, results)
|
a.parseAuthenticationResultsHeader(header, results)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func TestParseARCResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -136,7 +136,7 @@ func TestValidateARCChain(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ func TestParseBIMIResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ func TestParseDKIMResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func TestParseDMARCResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ func TestParseIPRevResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -181,7 +181,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ func TestParseSPFResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -161,7 +161,7 @@ func TestParseLegacySPF(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ func TestGetAuthenticationScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
scorer := NewAuthenticationAnalyzer()
|
scorer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -247,7 +247,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -353,7 +353,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
|
|
||||||
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||||
// This test verifies that only the first occurrence of each auth method is parsed
|
// This test verifies that only the first occurrence of each auth method is parsed
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ func TestParseXAlignedFromResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -126,7 +126,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ func TestParseXGoogleDKIMResult(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ 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, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.HeaderAnalysis) *api.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 &api.DNSResults{
|
||||||
|
|
@ -104,19 +104,14 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
|
||||||
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
// SPF validates the MAIL FROM command, which corresponds to Return-Path
|
||||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||||
|
|
||||||
// Check DKIM records (from authentication results)
|
// Check DKIM records by parsing DKIM-Signature headers directly
|
||||||
// DKIM can be for any domain, but typically the From domain
|
for _, sig := range parseDKIMSignatures(email.Header["DKIM-Signature"]) {
|
||||||
if authResults != nil && authResults.Dkim != nil {
|
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
||||||
for _, dkim := range *authResults.Dkim {
|
if dkimRecord != nil {
|
||||||
if dkim.Domain != nil && dkim.Selector != nil {
|
if results.DkimRecords == nil {
|
||||||
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
|
results.DkimRecords = new([]api.DKIMRecord)
|
||||||
if dkimRecord != nil {
|
|
||||||
if results.DkimRecords == nil {
|
|
||||||
results.DkimRecords = new([]api.DKIMRecord)
|
|
||||||
}
|
|
||||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,38 @@ import (
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header.
|
||||||
|
type DKIMHeader struct {
|
||||||
|
Domain string
|
||||||
|
Selector string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values.
|
||||||
|
func parseDKIMSignatures(signatures []string) []DKIMHeader {
|
||||||
|
var results []DKIMHeader
|
||||||
|
for _, sig := range signatures {
|
||||||
|
var domain, selector string
|
||||||
|
for _, part := range strings.Split(sig, ";") {
|
||||||
|
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(kv[0])
|
||||||
|
val := strings.TrimSpace(kv[1])
|
||||||
|
switch key {
|
||||||
|
case "d":
|
||||||
|
domain = val
|
||||||
|
case "s":
|
||||||
|
selector = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if domain != "" && selector != "" {
|
||||||
|
results = append(results, DKIMHeader{Domain: domain, Selector: selector})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
// checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector
|
// checkapi.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) *api.DKIMRecord {
|
||||||
// DKIM records are at: selector._domainkey.domain
|
// DKIM records are at: selector._domainkey.domain
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,220 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestParseDKIMSignatures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
signatures []string
|
||||||
|
expected []DKIMHeader
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty input",
|
||||||
|
signatures: nil,
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty string",
|
||||||
|
signatures: []string{""},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple Gmail-style",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Microsoft 365 style",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tab-folded multiline (Postfix-style)",
|
||||||
|
signatures: []string{
|
||||||
|
"v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==",
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Space-folded multiline (RFC-style)",
|
||||||
|
signatures: []string{
|
||||||
|
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==",
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "d= and s= on separate continuation lines",
|
||||||
|
signatures: []string{
|
||||||
|
"v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==",
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No space after semicolons",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple spaces after semicolons",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ed25519 signature (RFC 8463)",
|
||||||
|
signatures: []string{
|
||||||
|
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==",
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple signatures (ESP double-signing)",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{
|
||||||
|
{Domain: "mydomain.com", Selector: "mail"},
|
||||||
|
{Domain: "sendib.com", Selector: "mail"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{
|
||||||
|
{Domain: "football.example.com", Selector: "brisbane"},
|
||||||
|
{Domain: "football.example.com", Selector: "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amazon SES long selectors",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{
|
||||||
|
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"},
|
||||||
|
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subdomain in d=",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Deeply nested subdomain",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Selector with hyphens (Microsoft 365 custom domain style)",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Selector with dots",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Single-character selector",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Postmark-style timestamp selector, s= before d=",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "d= and s= at the very end",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full tag set",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing d= tag",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing s= tag",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing both d= and s= tags",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mix of valid and invalid signatures",
|
||||||
|
signatures: []string{
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`,
|
||||||
|
},
|
||||||
|
expected: []DKIMHeader{
|
||||||
|
{Domain: "good.com", Selector: "sel1"},
|
||||||
|
{Domain: "also-good.com", Selector: "sel2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := parseDKIMSignatures(tt.signatures)
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected)
|
||||||
|
}
|
||||||
|
for i := range tt.expected {
|
||||||
|
if result[i].Domain != tt.expected[i].Domain {
|
||||||
|
t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain)
|
||||||
|
}
|
||||||
|
if result[i].Selector != tt.expected[i].Selector {
|
||||||
|
t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateDKIM(t *testing.T) {
|
func TestValidateDKIM(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,9 @@ import (
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var hostname = ""
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
hostname, _ = os.Hostname()
|
|
||||||
}
|
|
||||||
|
|
||||||
// EmailMessage represents a parsed email message
|
// EmailMessage represents a parsed email message
|
||||||
type EmailMessage struct {
|
type EmailMessage struct {
|
||||||
Header mail.Header
|
Header mail.Header
|
||||||
|
|
@ -218,18 +211,18 @@ func buildRawHeaders(header mail.Header) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuthenticationResults extracts Authentication-Results headers
|
// GetAuthenticationResults extracts Authentication-Results headers
|
||||||
// If hostname is provided, only returns headers that begin with that hostname
|
// If receiverHostname is provided, only returns headers that begin with that hostname
|
||||||
func (e *EmailMessage) GetAuthenticationResults() []string {
|
func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
|
||||||
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||||
|
|
||||||
// If no hostname specified, return all results
|
// If no hostname specified, return all results
|
||||||
if hostname == "" {
|
if receiverHostname == "" {
|
||||||
return allResults
|
return allResults
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter results that begin with the specified hostname
|
// Filter results that begin with the specified hostname
|
||||||
var filtered []string
|
var filtered []string
|
||||||
prefix := hostname + ";"
|
prefix := receiverHostname + ";"
|
||||||
for _, result := range allResults {
|
for _, result := range allResults {
|
||||||
// Trim whitespace and check if it starts with hostname;
|
// Trim whitespace and check if it starts with hostname;
|
||||||
trimmed := strings.TrimSpace(result)
|
trimmed := strings.TrimSpace(result)
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,6 @@ Content-Type: text/html; charset=utf-8
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAuthenticationResults(t *testing.T) {
|
func TestGetAuthenticationResults(t *testing.T) {
|
||||||
// Force hostname
|
|
||||||
hostname = "example.com"
|
|
||||||
|
|
||||||
rawEmail := `From: sender@example.com
|
rawEmail := `From: sender@example.com
|
||||||
To: recipient@example.com
|
To: recipient@example.com
|
||||||
Subject: Test Email
|
Subject: Test Email
|
||||||
|
|
@ -123,7 +120,7 @@ Body content.
|
||||||
t.Fatalf("Failed to parse email: %v", err)
|
t.Fatalf("Failed to parse email: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authResults := email.GetAuthenticationResults()
|
authResults := email.GetAuthenticationResults("example.com")
|
||||||
if len(authResults) != 2 {
|
if len(authResults) != 2 {
|
||||||
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ type ReportGenerator struct {
|
||||||
|
|
||||||
// NewReportGenerator creates a new report generator
|
// NewReportGenerator creates a new report generator
|
||||||
func NewReportGenerator(
|
func NewReportGenerator(
|
||||||
|
receiverHostname string,
|
||||||
dnsTimeout time.Duration,
|
dnsTimeout time.Duration,
|
||||||
httpTimeout time.Duration,
|
httpTimeout time.Duration,
|
||||||
rbls []string,
|
rbls []string,
|
||||||
|
|
@ -50,7 +51,7 @@ func NewReportGenerator(
|
||||||
checkAllIPs bool,
|
checkAllIPs bool,
|
||||||
) *ReportGenerator {
|
) *ReportGenerator {
|
||||||
return &ReportGenerator{
|
return &ReportGenerator{
|
||||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
||||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||||
rspamdAnalyzer: NewRspamdAnalyzer(),
|
rspamdAnalyzer: NewRspamdAnalyzer(),
|
||||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||||
|
|
@ -83,7 +84,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
// Run all analyzers
|
// Run all analyzers
|
||||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers)
|
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
||||||
results.RBL = r.rblChecker.CheckEmail(email)
|
results.RBL = r.rblChecker.CheckEmail(email)
|
||||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewReportGenerator(t *testing.T) {
|
func TestNewReportGenerator(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
if gen == nil {
|
if gen == nil {
|
||||||
t.Fatal("Expected report generator, got nil")
|
t.Fatal("Expected report generator, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyzeEmail(t *testing.T) {
|
func TestAnalyzeEmail(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReport(t *testing.T) {
|
func TestGenerateReport(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
testID := uuid.New()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmail()
|
email := createTestEmail()
|
||||||
|
|
@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
testID := uuid.New()
|
testID := uuid.New()
|
||||||
|
|
||||||
email := createTestEmailWithSpamAssassin()
|
email := createTestEmailWithSpamAssassin()
|
||||||
|
|
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateRawEmail(t *testing.T) {
|
func TestGenerateRawEmail(t *testing.T) {
|
||||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
gen := NewReportGenerator("", 10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,13 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require at least X-Spamd-Result or X-Rspamd-Score to produce a meaningful report
|
||||||
|
_, hasSpamdResult := headers["X-Spamd-Result"]
|
||||||
|
_, hasRspamdScore := headers["X-Rspamd-Score"]
|
||||||
|
if !hasSpamdResult && !hasRspamdScore {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
result := &api.RspamdResult{
|
result := &api.RspamdResult{
|
||||||
Symbols: make(map[string]api.RspamdSymbol),
|
Symbols: make(map[string]api.RspamdSymbol),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require at least X-Spam-Status, X-Spam-Score, or X-Spam-Flag to produce a meaningful report
|
||||||
|
_, hasStatus := headers["X-Spam-Status"]
|
||||||
|
_, hasScore := headers["X-Spam-Score"]
|
||||||
|
_, hasFlag := headers["X-Spam-Flag"]
|
||||||
|
if !hasStatus && !hasScore && !hasFlag {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
result := &api.SpamAssassinResult{
|
result := &api.SpamAssassinResult{
|
||||||
TestDetails: make(map[string]api.SpamTestDetail),
|
TestDetails: make(map[string]api.SpamTestDetail),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@
|
||||||
|
|
||||||
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
|
||||||
|
|
||||||
|
let allRequiredMissing = $derived(
|
||||||
|
!authentication.spf &&
|
||||||
|
(!authentication.dkim || authentication.dkim.length === 0) &&
|
||||||
|
!authentication.dmarc,
|
||||||
|
);
|
||||||
|
|
||||||
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
function getAuthResultClass(result: string, noneIsFail: boolean): string {
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case "pass":
|
case "pass":
|
||||||
|
|
@ -97,6 +103,28 @@
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
{#if allRequiredMissing}
|
||||||
|
<div class="card-body border-bottom">
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<strong>No authentication results found.</strong>
|
||||||
|
<p class="mb-0 mt-1">
|
||||||
|
This usually means either:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-0 mt-1">
|
||||||
|
<li>
|
||||||
|
The receiving mail server is not configured to verify email authentication
|
||||||
|
(no <code>Authentication-Results</code> header was found in the message).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The <code>Authentication-Results</code> header exists but the receiver
|
||||||
|
hostname does not match the configured
|
||||||
|
<code>--receiver-hostname</code> value.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<!-- IPREV -->
|
<!-- IPREV -->
|
||||||
{#if authentication.iprev}
|
{#if authentication.iprev}
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,32 @@
|
||||||
|
|
||||||
// Email sender information
|
// Email sender information
|
||||||
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
||||||
const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0;
|
const hasDkim =
|
||||||
const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass");
|
report.dns_results?.dkim_records && report.dns_results?.dkim_records?.length > 0;
|
||||||
|
const dkimPassed =
|
||||||
|
report.authentication?.dkim &&
|
||||||
|
report.authentication?.dkim.length > 0 &&
|
||||||
|
report.authentication?.dkim?.some((d) => d.result === "pass");
|
||||||
|
|
||||||
segments.push({ text: "Received a " });
|
segments.push({ text: "Received a " });
|
||||||
segments.push({
|
segments.push({
|
||||||
text: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
|
text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
|
||||||
highlight: { color: dkimPassed ? "good" : "danger", bold: true },
|
highlight: {
|
||||||
link: "#authentication-dkim",
|
color: hasDkim ? (dkimPassed ? "good" : "warning") : "danger",
|
||||||
|
bold: true,
|
||||||
|
},
|
||||||
|
link: hasDkim && dkimPassed ? "#authentication-dkim" : "#dns-details",
|
||||||
});
|
});
|
||||||
segments.push({ text: " email from " });
|
segments.push({ text: " email" });
|
||||||
|
if (hasDkim && !dkimPassed) {
|
||||||
|
segments.push({ text: " with " });
|
||||||
|
segments.push({
|
||||||
|
text: "an invalid signature",
|
||||||
|
highlight: { color: "danger", bold: true },
|
||||||
|
link: "#authentication-dkim",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
segments.push({ text: " from " });
|
||||||
segments.push({
|
segments.push({
|
||||||
text: mailFrom,
|
text: mailFrom,
|
||||||
highlight: { emphasis: true },
|
highlight: { emphasis: true },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue