Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ KEY_FILE=

ENABLE_SPF="true"
ENABLE_DKIM="true"
ENABLE_DKIM2="true"
ENABLE_DMARC="true"
VERBOSE="true"
# Metrics settings
Expand Down
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Auto detect text files and perform LF normalization
* text=auto

# DKIM2 fixtures are signed over their exact CRLF bytes.
internal/auth/testdata/*.eml -text
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ A SMTP server that receives emails from any domain without authentication and fo

- **SPF Validation (RFC 7208)**: Validates Sender Policy Framework with HELO and envelope sender
- **DKIM Validation (RFC 6376)**: Verifies DomainKeys Identified Mail signatures with multi-signature support
- **DKIM2 Verification (draft-ietf-dkim-dkim2-spec-09)**: Verifies DKIM2 signatures against the SMTP envelope and reports the result next to DKIM. It does not accept or reject mail, and it is not an input to DMARC
- **DMARC Validation (RFC 7489)**: Evaluates DMARC policy with hierarchical domain lookup and subdomain policy inheritance
- **Comprehensive Results**: Detailed authentication results with domain, mechanism, and alignment data
- **Domain Hierarchy Tracking**: Tracks all attempted DMARC lookups with detailed per-domain results
Expand Down Expand Up @@ -136,6 +137,7 @@ Usage of ./inboundparse:
-message-processing-timeout int Per-message processing timeout in seconds, including auth and webhook (default 300)
-enable-spf Enable SPF validation (default true)
-enable-dkim Enable DKIM validation (default true)
-enable-dkim2 Enable DKIM2 verification (default true)
-enable-dmarc Enable DMARC validation (default true)
-cert-file string TLS cert file (optional)
-key-file string TLS private key (optional)
Expand Down Expand Up @@ -197,6 +199,7 @@ The image entrypoint requests a Let's Encrypt certificate on startup when DNS cr

- `ENABLE_SPF` Enable SPF checking (`true`/`false`, default: `true`)
- `ENABLE_DKIM` Enable DKIM checking (`true`/`false`, default: `true`)
- `ENABLE_DKIM2` Enable DKIM2 verification (`true`/`false`, default: `true`)
- `ENABLE_DMARC` Enable DMARC checking (`true`/`false`, default: `true`)
- `VERBOSE` Enable verbose (debug) logging (`true`/`false`, default: `false`)

Expand All @@ -219,7 +222,9 @@ When metrics are enabled on a non-loopback address (`0.0.0.0`, public IP, etc.),

## Webhook Payload

The webhook receives a JSON document shaped like `domain.WebhookPayload`: message metadata and `authentication_results` for SPF/DKIM/DMARC. `from` and `to` are `net/mail.Address` values (JSON fields **`Name`** and **`Address`**). `timestamp` is taken from the parsed `Date` header when present. Attachments and inline parts use the structures from [`letters`](https://github.com/mnako/letters).
The webhook receives a JSON document shaped like `domain.WebhookPayload`: message metadata and `authentication_results` for SPF, DKIM, DKIM2, and DMARC. `from` and `to` are `net/mail.Address` values (JSON fields **`Name`** and **`Address`**). `timestamp` is taken from the parsed `Date` header when present. Attachments and inline parts use the structures from [`letters`](https://github.com/mnako/letters).

`authentication_results.dkim2` is the draft-09 verification state. `result` is `none` when the message has no DKIM2 fields, and otherwise `pass`, `fail`, `temperror`, or `permerror`. `reason` is the library reason code (`none` on pass, `missing_key`, `envelope_mismatch`, and the rest of that closed set). A canceled check is `temperror` with reason `unavailable`. The signing domain is not included. `signatures[].selector` is present only for a signature set that did not pass. DKIM2 is not used when evaluating DMARC.

```json
{
Expand Down Expand Up @@ -263,6 +268,32 @@ The webhook receives a JSON document shaped like `domain.WebhookPayload`: messag
"raw": "",
"error": ""
},
"dkim2": {
"result": "pass",
"reason": "none",
"scope": "chain",
"historical_content": "complete",
"historical_signatures": "complete",
"custody": "not_present",
"sequence": 1,
"instance": 1,
"checks": [
{ "class": "body_hash", "reason": "none" },
{ "class": "domain_alignment", "reason": "none" },
{ "class": "envelope", "reason": "none" },
{ "class": "header_hash", "reason": "none" },
{ "class": "next_domain", "reason": "none" },
{ "class": "signature", "reason": "none" },
{ "class": "timestamp", "reason": "none" }
],
"signatures": [
{
"algorithm": "ed25519-sha256",
"status": "pass",
"reason": "none"
}
]
},
"dmarc": {
"result": "pass",
"raw": "v=DMARC1; p=reject; ...",
Expand Down
13 changes: 7 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module inboundparse
go 1.27.3

require (
github.com/croessner/dkim2 v0.3.3
github.com/emersion/go-msgauth v0.7.0
github.com/emersion/go-smtp v0.24.0
github.com/failsafe-go/failsafe-go v0.9.6
Expand All @@ -15,8 +16,8 @@ require (
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/zaccone/spf v0.0.0-20170817004109-76747b8658d9
golang.org/x/net v0.60.0
golang.org/x/sync v0.23.0
golang.org/x/net v0.61.0
golang.org/x/sync v0.24.0
)

require (
Expand All @@ -35,11 +36,11 @@ require (
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.49.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/tools v0.51.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
26 changes: 14 additions & 12 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/croessner/dkim2 v0.3.3 h1:ZW/ZNF+t/xNOK7fXVXPywI0RJGcx1VHVbwnr5z82UKk=
github.com/croessner/dkim2 v0.3.3/go.mod h1:YiC5Mrzce8u0KBcZNYmwno2tJsn0/QGiuXqoKgivWc4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
Expand Down Expand Up @@ -73,20 +75,20 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
Expand Down
31 changes: 28 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import (
"inboundparse/internal/observability"
"inboundparse/internal/processor"
"inboundparse/internal/smtp"
"net"
"os"
"os/signal"
"syscall"
"time"

"github.com/croessner/dkim2"
)

// App represents the application
Expand Down Expand Up @@ -60,11 +63,31 @@ func NewApp(cfg *config.Config) (*App, error) {
metrics = &observability.NoOpMetricsCollector{}
}

// One verifier for the process. DNSPublicKeyProvider also supplies the
// draft-09 From-subdomain policy, so NewVerifier keeps that check on.
var dkim2Verifier *dkim2.Verifier
if cfg.EnableDKIM2 {
transport, err := dkim2.NewNetTXTTransport(net.DefaultResolver)
if err != nil {
return nil, fmt.Errorf("failed to create DKIM2 DNS transport: %w", err)
}
provider, err := dkim2.NewDNSPublicKeyProvider(transport)
if err != nil {
return nil, fmt.Errorf("failed to create DKIM2 key provider: %w", err)
}
dkim2Verifier, err = dkim2.NewVerifier(provider)
if err != nil {
return nil, fmt.Errorf("failed to create DKIM2 verifier: %w", err)
}
}

// Initialize auth checker
authChecker := auth.NewAuthChecker(auth.AuthConfig{
EnableSPF: cfg.EnableSPF,
EnableDKIM: cfg.EnableDKIM,
EnableDMARC: cfg.EnableDMARC,
EnableSPF: cfg.EnableSPF,
EnableDKIM: cfg.EnableDKIM,
EnableDKIM2: cfg.EnableDKIM2,
EnableDMARC: cfg.EnableDMARC,
DKIM2Verifier: dkim2Verifier,
})

// Initialize webhook sender
Expand Down Expand Up @@ -95,6 +118,7 @@ func NewApp(cfg *config.Config) (*App, error) {
processor.ProcessorConfig{
EnableSPF: cfg.EnableSPF,
EnableDKIM: cfg.EnableDKIM,
EnableDKIM2: cfg.EnableDKIM2,
EnableDMARC: cfg.EnableDMARC,
},
authChecker,
Expand Down Expand Up @@ -166,6 +190,7 @@ func (a *App) Start() error {
Str("smtp_tls_address", a.config.ListenAddrTLS).
Bool("spf_enabled", a.config.EnableSPF).
Bool("dkim_enabled", a.config.EnableDKIM).
Bool("dkim2_enabled", a.config.EnableDKIM2).
Bool("dmarc_enabled", a.config.EnableDMARC).
Bool("metrics_enabled", a.config.EnableMetrics).
Bool("sentry_enabled", a.config.EnableSentry).
Expand Down
21 changes: 21 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ func (m *mockAuthChecker) CheckDKIM(ctx context.Context, rawMessage string, logg
return nil, nil
}

func (m *mockAuthChecker) CheckDKIM2(ctx context.Context, rawMessage string, from string, to []string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIM2Result, error) {
return nil, nil
}

func (m *mockAuthChecker) CheckDMARC(ctx context.Context, rawMessage string, headers interface{}, spfResult *domain.SPFResult, dkimResult *domain.DKIMResult, logger observability.Logger) (*domain.DMARCResult, error) {
return nil, nil
}
Expand Down Expand Up @@ -143,6 +147,23 @@ func TestNewApp(t *testing.T) {
}
}

func TestNewApp_DKIM2Enabled(t *testing.T) {
config := &config.Config{
ListenAddr: "127.0.0.1:0",
WebhookURL: "http://localhost:8080/webhook",
ServerName: "test",
EnableDKIM2: true,
}

app, err := NewApp(config)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if app == nil || app.authChecker == nil {
t.Fatal("Expected app with an auth checker")
}
}

func TestNewApp_InvalidConfig(t *testing.T) {
// Test with nil config - this should return an error
app, err := NewApp(nil)
Expand Down
140 changes: 140 additions & 0 deletions internal/auth/dkim2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package auth

import (
"context"
"errors"
"inboundparse/internal/domain"
"inboundparse/internal/observability"
"strings"
"time"

"github.com/croessner/dkim2"
)

// dkim2Checker verifies inbound messages with a process-wide Verifier.
// Signing stays out of this path.
type dkim2Checker struct {
verifier *dkim2.Verifier
}

// NewDKIM2Checker returns a checker that reuses verifier.
func NewDKIM2Checker(verifier *dkim2.Verifier) DKIM2Checker {
return &dkim2Checker{verifier: verifier}
}

// CheckDKIM2 reports draft-09 verification. A message with no DKIM2 fields is
// none. Protocol failures stay on the result. A canceled context is temperror
// with reason unavailable so the webhook still receives a value.
func (c *dkim2Checker) CheckDKIM2(ctx context.Context, rawMessage string, from string, to []string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIM2Result, error) {
start := time.Now()
defer func() {
metrics.TrackDKIM2Duration(time.Since(start))
}()

if c == nil || c.verifier == nil {
metrics.TrackAuthError("dkim2", "verifier_error")
logger.Error().Msg("DKIM2 verifier is not configured")
return &domain.DKIM2Result{Result: "permerror", Reason: "invalid_request"}, nil
}

reverse := bracketEnvelopePath(from)
forwards := make([][]byte, len(to))
for i, rcpt := range to {
forwards[i] = []byte(bracketEnvelopePath(rcpt))
}

assessment, err := c.verifier.Assess(ctx, dkim2.NewVerifyRequest([]byte(rawMessage), []byte(reverse), forwards))
if err != nil {
if canceled(ctx, err) {
metrics.TrackAuthError("dkim2", "canceled")
logger.Warn().
Err(err).
Str("result", "temperror").
Str("reason", "unavailable").
Msg("DKIM2 verification canceled")
return &domain.DKIM2Result{Result: "temperror", Reason: "unavailable"}, nil
}
metrics.TrackAuthError("dkim2", "verifier_error")
logger.Warn().
Err(err).
Str("result", "permerror").
Str("reason", "invalid_request").
Msg("DKIM2 verification error")
return &domain.DKIM2Result{Result: "permerror", Reason: "invalid_request"}, nil
}

result := mapDKIM2Assessment(assessment)
logger.Debug().
Str("result", result.Result).
Str("reason", result.Reason).
Msg("DKIM2 verification completed")
return result, nil
}

// bracketEnvelopePath turns the SMTP envelope into the bracketed form DKIM2
// compares. A null reverse-path arrives as "" and must be sent as "<>".
// Local parts stay case-sensitive. Paths that are already bracketed are kept.
func bracketEnvelopePath(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return "<>"
}
if len(addr) >= 2 && strings.HasPrefix(addr, "<") && strings.HasSuffix(addr, ">") {
return addr
}
return "<" + addr + ">"
}

func canceled(ctx context.Context, err error) bool {
if ctx != nil && ctx.Err() != nil {
return true
}
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}

func mapDKIM2Assessment(assessment dkim2.VerificationAssessment) *domain.DKIM2Result {
if !assessment.Applicable() {
return &domain.DKIM2Result{Result: "none"}
}
verification, ok := assessment.Verification()
if !ok {
return &domain.DKIM2Result{Result: "permerror", Reason: "invalid_request"}
}

checks := verification.Checks()
mappedChecks := make([]domain.DKIM2Check, 0, len(checks))
for _, check := range checks {
mappedChecks = append(mappedChecks, domain.DKIM2Check{
Class: string(check.Class()),
Reason: string(check.Reason()),
})
}

sets := verification.SignatureSets()
signatures := make([]domain.DKIM2Signature, 0, len(sets))
for _, set := range sets {
signature := domain.DKIM2Signature{
Algorithm: string(set.Algorithm()),
Status: string(set.Status()),
Reason: string(set.Reason()),
}
if selector, present := set.Selector(); present {
signature.Selector = selector
}
signatures = append(signatures, signature)
}

target := verification.Target()
return &domain.DKIM2Result{
Result: strings.ToLower(string(verification.State())),
Reason: string(verification.PrimaryReason()),
Scope: string(verification.Scope()),
HistoricalContent: string(verification.HistoricalContent()),
HistoricalSignatures: string(verification.HistoricalSignatures()),
Custody: string(verification.CustodyStructure()),
Sequence: target.Sequence(),
Instance: target.Instance(),
Checks: mappedChecks,
Signatures: signatures,
}
}
Loading
Loading