Compare commits
8 commits
5647df3072
...
6741579000
| Author | SHA1 | Date | |
|---|---|---|---|
| 6741579000 | |||
| 3c192f17fd | |||
| 35fc997390 | |||
| 2fcee1b885 | |||
| 26025c96a2 | |||
| 76ee50a100 | |||
| 71e0832416 | |||
| c96a8b92b8 |
27 changed files with 417 additions and 63 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, ...
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -110,14 +110,38 @@ Default configuration for the Docker environment:
|
|||
The container accepts these environment variables:
|
||||
|
||||
- `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
|
||||
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
|
||||
|
||||
**Required volumes:**
|
||||
|
|
|
|||
|
|
@ -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.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.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.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)")
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ import (
|
|||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
func getHostname() string {
|
||||
h, _ := os.Hostname()
|
||||
return h
|
||||
}
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
DevProxy string
|
||||
|
|
@ -58,6 +63,7 @@ type EmailConfig struct {
|
|||
Domain string
|
||||
TestAddressPrefix string
|
||||
LMTPAddr string
|
||||
ReceiverHostname string
|
||||
}
|
||||
|
||||
// AnalysisConfig contains timeout and behavior settings for email analysis
|
||||
|
|
@ -84,6 +90,7 @@ func DefaultConfig() *Config {
|
|||
Domain: "happydeliver.local",
|
||||
TestAddressPrefix: "test-",
|
||||
LMTPAddr: "127.0.0.1:2525",
|
||||
ReceiverHostname: getHostname(),
|
||||
},
|
||||
Analysis: AnalysisConfig{
|
||||
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)
|
||||
|
||||
// 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
|
||||
reportJSON, err := json.Marshal(result.Report)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type EmailAnalyzer struct {
|
|||
// NewEmailAnalyzer creates a new email analyzer with the given configuration
|
||||
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
|
||||
generator := NewReportGenerator(
|
||||
cfg.Email.ReceiverHostname,
|
||||
cfg.Analysis.DNSTimeout,
|
||||
cfg.Analysis.HTTPTimeout,
|
||||
cfg.Analysis.RBLs,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ import (
|
|||
)
|
||||
|
||||
// AuthenticationAnalyzer analyzes email authentication results
|
||||
type AuthenticationAnalyzer struct{}
|
||||
type AuthenticationAnalyzer struct {
|
||||
receiverHostname string
|
||||
}
|
||||
|
||||
// NewAuthenticationAnalyzer creates a new authentication analyzer
|
||||
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
|
||||
return &AuthenticationAnalyzer{}
|
||||
func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer {
|
||||
return &AuthenticationAnalyzer{receiverHostname: receiverHostname}
|
||||
}
|
||||
|
||||
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
|
||||
|
|
@ -40,7 +42,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
|||
results := &api.AuthenticationResults{}
|
||||
|
||||
// Parse Authentication-Results headers
|
||||
authHeaders := email.GetAuthenticationResults()
|
||||
authHeaders := email.GetAuthenticationResults(a.receiverHostname)
|
||||
for _, header := range authHeaders {
|
||||
a.parseAuthenticationResultsHeader(header, results)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func TestParseARCResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
analyzer := NewAuthenticationAnalyzer("")
|
||||
|
||||
for _, tt := range tests {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -353,7 +353,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
|||
|
||||
func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) {
|
||||
// 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) {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
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
|
||||
if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" {
|
||||
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
|
||||
results.SpfRecords = d.checkSPFRecords(spfDomain)
|
||||
|
||||
// Check DKIM records (from authentication results)
|
||||
// DKIM can be for any domain, but typically the From domain
|
||||
if authResults != nil && authResults.Dkim != nil {
|
||||
for _, dkim := range *authResults.Dkim {
|
||||
if dkim.Domain != nil && dkim.Selector != nil {
|
||||
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
|
||||
if dkimRecord != nil {
|
||||
if results.DkimRecords == nil {
|
||||
results.DkimRecords = new([]api.DKIMRecord)
|
||||
}
|
||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||
}
|
||||
// Check DKIM records by parsing DKIM-Signature headers directly
|
||||
for _, sig := range parseDKIMSignatures(email.Header["DKIM-Signature"]) {
|
||||
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector)
|
||||
if dkimRecord != nil {
|
||||
if results.DkimRecords == nil {
|
||||
results.DkimRecords = new([]api.DKIMRecord)
|
||||
}
|
||||
*results.DkimRecords = append(*results.DkimRecords, *dkimRecord)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,38 @@ import (
|
|||
"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
|
||||
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord {
|
||||
// DKIM records are at: selector._domainkey.domain
|
||||
|
|
|
|||
|
|
@ -26,6 +26,220 @@ import (
|
|||
"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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -28,16 +28,9 @@ import (
|
|||
"mime/multipart"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var hostname = ""
|
||||
|
||||
func init() {
|
||||
hostname, _ = os.Hostname()
|
||||
}
|
||||
|
||||
// EmailMessage represents a parsed email message
|
||||
type EmailMessage struct {
|
||||
Header mail.Header
|
||||
|
|
@ -218,18 +211,18 @@ func buildRawHeaders(header mail.Header) string {
|
|||
}
|
||||
|
||||
// GetAuthenticationResults extracts Authentication-Results headers
|
||||
// If hostname is provided, only returns headers that begin with that hostname
|
||||
func (e *EmailMessage) GetAuthenticationResults() []string {
|
||||
// If receiverHostname is provided, only returns headers that begin with that hostname
|
||||
func (e *EmailMessage) GetAuthenticationResults(receiverHostname string) []string {
|
||||
allResults := e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
|
||||
|
||||
// If no hostname specified, return all results
|
||||
if hostname == "" {
|
||||
if receiverHostname == "" {
|
||||
return allResults
|
||||
}
|
||||
|
||||
// Filter results that begin with the specified hostname
|
||||
var filtered []string
|
||||
prefix := hostname + ";"
|
||||
prefix := receiverHostname + ";"
|
||||
for _, result := range allResults {
|
||||
// Trim whitespace and check if it starts with hostname;
|
||||
trimmed := strings.TrimSpace(result)
|
||||
|
|
|
|||
|
|
@ -106,9 +106,6 @@ Content-Type: text/html; charset=utf-8
|
|||
}
|
||||
|
||||
func TestGetAuthenticationResults(t *testing.T) {
|
||||
// Force hostname
|
||||
hostname = "example.com"
|
||||
|
||||
rawEmail := `From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test Email
|
||||
|
|
@ -123,7 +120,7 @@ Body content.
|
|||
t.Fatalf("Failed to parse email: %v", err)
|
||||
}
|
||||
|
||||
authResults := email.GetAuthenticationResults()
|
||||
authResults := email.GetAuthenticationResults("example.com")
|
||||
if len(authResults) != 2 {
|
||||
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ type ReportGenerator struct {
|
|||
|
||||
// NewReportGenerator creates a new report generator
|
||||
func NewReportGenerator(
|
||||
receiverHostname string,
|
||||
dnsTimeout time.Duration,
|
||||
httpTimeout time.Duration,
|
||||
rbls []string,
|
||||
|
|
@ -50,7 +51,7 @@ func NewReportGenerator(
|
|||
checkAllIPs bool,
|
||||
) *ReportGenerator {
|
||||
return &ReportGenerator{
|
||||
authAnalyzer: NewAuthenticationAnalyzer(),
|
||||
authAnalyzer: NewAuthenticationAnalyzer(receiverHostname),
|
||||
spamAnalyzer: NewSpamAssassinAnalyzer(),
|
||||
rspamdAnalyzer: NewRspamdAnalyzer(),
|
||||
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
|
||||
|
|
@ -83,7 +84,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
|||
// Run all analyzers
|
||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||
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.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import (
|
|||
)
|
||||
|
||||
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 {
|
||||
t.Fatal("Expected report generator, got nil")
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ func TestNewReportGenerator(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()
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ func TestAnalyzeEmail(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()
|
||||
|
||||
email := createTestEmail()
|
||||
|
|
@ -130,7 +130,7 @@ func TestGenerateReport(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()
|
||||
|
||||
email := createTestEmailWithSpamAssassin()
|
||||
|
|
@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(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 {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult {
|
|||
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{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *api.Spa
|
|||
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{
|
||||
TestDetails: make(map[string]api.SpamTestDetail),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@
|
|||
|
||||
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 {
|
||||
switch (result) {
|
||||
case "pass":
|
||||
|
|
@ -97,6 +103,28 @@
|
|||
</span>
|
||||
</h4>
|
||||
</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">
|
||||
<!-- IPREV -->
|
||||
{#if authentication.iprev}
|
||||
|
|
|
|||
|
|
@ -25,16 +25,32 @@
|
|||
|
||||
// Email sender information
|
||||
const mailFrom = report.header_analysis?.headers?.from?.value || "an unknown sender";
|
||||
const hasDkim = report.authentication?.dkim && report.authentication?.dkim.length > 0;
|
||||
const dkimPassed = hasDkim && report.authentication?.dkim?.some((d) => d.result === "pass");
|
||||
const hasDkim =
|
||||
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: dkimPassed ? "DKIM-signed" : "non-DKIM-signed",
|
||||
highlight: { color: dkimPassed ? "good" : "danger", bold: true },
|
||||
link: "#authentication-dkim",
|
||||
text: hasDkim ? "DKIM-signed" : "non-DKIM-signed",
|
||||
highlight: {
|
||||
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({
|
||||
text: mailFrom,
|
||||
highlight: { emphasis: true },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue