first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
// Package pipeline implements the inbound security pipeline: SPF, DKIM
// verification, DMARC, header/URL heuristics — each stage contributes a
// score, and the orchestrator maps the total score to a verdict (clean,
// flagged, quarantine, blocked) per the configured thresholds.
//
// Every stage is blocking and runs before the SMTP DATA response — no
// third-party spam-filtering library, entirely stdlib DNS/crypto/net/mail.
package pipeline
import (
"context"
"net"
"net/mail"
"strings"
"time"
"gomail/internal/config"
"gomail/internal/db"
)
// MailContext carries everything a stage needs and accumulates results.
type MailContext struct {
SenderIP net.IP
SenderHost string
MailFrom string
RcptTo string
RawMessage []byte
Checks []StageResult
TotalScore float64
Verdict db.MessageVerdict
parsedMessage *mail.Message
parseErr error
parseAttempted bool
}
// ParsedMessage lazily parses RawMessage via net/mail — stages call this
// instead of parsing independently, so the (relatively expensive) header
// parse happens at most once per message regardless of how many stages need it.
func (mc *MailContext) ParsedMessage() (*mail.Message, error) {
if !mc.parseAttempted {
mc.parsedMessage, mc.parseErr = mail.ReadMessage(strings.NewReader(string(mc.RawMessage)))
mc.parseAttempted = true
}
return mc.parsedMessage, mc.parseErr
}
// RcptDomain returns the domain portion of RcptTo.
func (mc *MailContext) RcptDomain() string {
return domainOf(mc.RcptTo)
}
// MailFromDomain returns the domain portion of the envelope sender.
func (mc *MailContext) MailFromDomain() string {
return domainOf(mc.MailFrom)
}
func domainOf(addr string) string {
parts := strings.SplitN(strings.ToLower(addr), "@", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
// StageResult is one check's outcome.
type StageResult struct {
Stage string
Result db.CheckResult
Score float64
Detail string
DurationMs int64
}
// Stage is one pipeline check. Run must not block indefinitely — pass a
// context with a deadline and respect it for any network I/O (DNS lookups).
type Stage interface {
Name() string
Run(ctx context.Context, mc *MailContext) *StageResult
}
// Orchestrator runs the configured stages in order and computes the verdict.
type Orchestrator struct {
stages []Stage
cfg *config.Config
}
func NewOrchestrator(cfg *config.Config, stages []Stage) *Orchestrator {
return &Orchestrator{stages: stages, cfg: cfg}
}
// DefaultStages returns the deterministic, always-on stage set (SPF, DKIM,
// DMARC, header anomaly, URL heuristics) — no external service
// dependencies, always safe to run regardless of what's configured.
func DefaultStages() []Stage {
return []Stage{
&SPFStage{},
&DKIMStage{},
&DMARCStage{},
&HeaderStage{},
&URLStage{},
}
}
// StagesFromConfig returns DefaultStages() plus any of the optional
// external-service stages (ClamAV, Rspamd, LLM) that config has an address
// configured for — each is entirely absent from the pipeline, not merely
// disabled, when its config field is empty, so an unreachable/misconfigured
// service that was never intended to be used can't accidentally affect
// delivery.
func StagesFromConfig(cfg *config.Config) []Stage {
stages := DefaultStages()
if cfg.Pipeline.ClamAVSocket != "" {
stages = append(stages, &ClamAVStage{Addr: cfg.Pipeline.ClamAVSocket, Timeout: 30 * time.Second})
}
if cfg.Pipeline.RspamdURL != "" {
stages = append(stages, &RspamdStage{BaseURL: cfg.Pipeline.RspamdURL, Timeout: 15 * time.Second})
}
if cfg.Pipeline.LLMURL != "" {
timeout := time.Duration(cfg.Pipeline.LLMTimeoutSecs) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
stages = append(stages, &LLMStage{BaseURL: cfg.Pipeline.LLMURL, Model: cfg.Pipeline.LLMModel, Timeout: timeout})
}
return stages
}
// Run executes every stage in order, accumulating score, and computes the
// final verdict against the configured thresholds. Individual stage panics
// are not recovered here deliberately — a panicking stage is a bug that
// should surface loudly in testing, not be silently swallowed in production
// and misclassify mail.
func (o *Orchestrator) Run(ctx context.Context, mc *MailContext) {
for _, stage := range o.stages {
start := time.Now()
result := stage.Run(ctx, mc)
if result == nil {
continue
}
result.DurationMs = time.Since(start).Milliseconds()
mc.Checks = append(mc.Checks, *result)
mc.TotalScore += result.Score
}
mc.Verdict = verdictFor(mc.TotalScore, o.cfg.Pipeline)
}
func verdictFor(score float64, p config.PipelineConfig) db.MessageVerdict {
switch {
case score >= p.ScoreBlock:
return db.VerdictBlocked
case score >= p.ScoreQuarantine:
return db.VerdictQuarantine
case score >= p.ScoreFlag:
return db.VerdictFlagged
default:
return db.VerdictClean
}
}
+127
View File
@@ -0,0 +1,127 @@
package pipeline
import (
"context"
"encoding/binary"
"fmt"
"net"
"strings"
"time"
"gomail/internal/db"
)
// ClamAVStage scans the raw message via clamd's INSTREAM protocol — a
// small, well-documented binary protocol (no third-party clamd client
// library): send "zINSTREAM\0", then the message in 4-byte-big-endian-
// length-prefixed chunks terminated by a zero-length chunk, then read the
// single-line response ("stream: OK", "stream: <name> FOUND", or
// "stream: <error>"). Off by default — only active when
// config.Pipeline.ClamAVSocket is set.
type ClamAVStage struct {
Addr string // "unix:/var/run/clamav/clamd.ctl" or "tcp:127.0.0.1:3310"
Timeout time.Duration
}
func (s *ClamAVStage) Name() string { return "clamav" }
func (s *ClamAVStage) Run(ctx context.Context, mc *MailContext) *StageResult {
start := time.Now()
result := &StageResult{Stage: s.Name()}
verdict, detail, err := s.scan(ctx, mc.RawMessage)
result.DurationMs = time.Since(start).Milliseconds()
if err != nil {
result.Result = db.CheckError
result.Detail = "clamd scan failed: " + err.Error()
return result
}
switch verdict {
case "FOUND":
result.Result = db.CheckFail
result.Score = 100 // malware is always a hard block, not a scored contribution
result.Detail = "malware detected: " + detail
case "OK":
result.Result = db.CheckPass
result.Detail = "clean"
default:
result.Result = db.CheckError
result.Detail = "unexpected clamd response: " + detail
}
return result
}
func (s *ClamAVStage) scan(ctx context.Context, raw []byte) (verdict, detail string, err error) {
network, address, err := parseClamAddr(s.Addr)
if err != nil {
return "", "", err
}
dialer := net.Dialer{Timeout: s.Timeout}
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return "", "", fmt.Errorf("connecting to clamd: %w", err)
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
conn.SetDeadline(deadline)
} else if s.Timeout > 0 {
conn.SetDeadline(time.Now().Add(s.Timeout))
}
if _, err := conn.Write([]byte("zINSTREAM\x00")); err != nil {
return "", "", fmt.Errorf("sending INSTREAM command: %w", err)
}
const chunkSize = 8192
for i := 0; i < len(raw); i += chunkSize {
end := i + chunkSize
if end > len(raw) {
end = len(raw)
}
chunk := raw[i:end]
lenBuf := make([]byte, 4)
binary.BigEndian.PutUint32(lenBuf, uint32(len(chunk)))
if _, err := conn.Write(lenBuf); err != nil {
return "", "", fmt.Errorf("writing chunk length: %w", err)
}
if _, err := conn.Write(chunk); err != nil {
return "", "", fmt.Errorf("writing chunk data: %w", err)
}
}
if _, err := conn.Write([]byte{0, 0, 0, 0}); err != nil {
return "", "", fmt.Errorf("writing terminator: %w", err)
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return "", "", fmt.Errorf("reading clamd response: %w", err)
}
response := strings.TrimRight(string(buf[:n]), "\x00\r\n")
switch {
case strings.HasSuffix(response, "OK"):
return "OK", response, nil
case strings.Contains(response, "FOUND"):
return "FOUND", response, nil
default:
return "ERROR", response, nil
}
}
// parseClamAddr accepts "unix:/path/to/socket" or "tcp:host:port" —
// explicit scheme prefix rather than sniffing, so a misconfigured address
// fails loudly at startup instead of guessing wrong.
func parseClamAddr(addr string) (network, address string, err error) {
switch {
case strings.HasPrefix(addr, "unix:"):
return "unix", strings.TrimPrefix(addr, "unix:"), nil
case strings.HasPrefix(addr, "tcp:"):
return "tcp", strings.TrimPrefix(addr, "tcp:"), nil
default:
return "", "", fmt.Errorf("clamav_socket must start with 'unix:' or 'tcp:', got %q", addr)
}
}
+56
View File
@@ -0,0 +1,56 @@
package pipeline
import (
"context"
"fmt"
"net"
"strings"
"gomail/internal/db"
"gomail/internal/dkim"
)
type DKIMStage struct{}
func (s *DKIMStage) Name() string { return "dkim" }
func (s *DKIMStage) Run(ctx context.Context, mc *MailContext) *StageResult {
domain, selector, found := dkim.ExtractSignatureInfo(mc.RawMessage)
if !found {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: "no DKIM-Signature header present"}
}
dnsHost := selector + "._domainkey." + domain
resolver := net.DefaultResolver
txts, err := resolver.LookupTXT(ctx, dnsHost)
if err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 8,
Detail: fmt.Sprintf("DKIM public key DNS lookup failed for %s: %v", dnsHost, err)}
}
var record string
for _, txt := range txts {
if strings.Contains(txt, "p=") {
record = txt
break
}
}
if record == "" {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 8,
Detail: fmt.Sprintf("no DKIM key record found at %s", dnsHost)}
}
pubDER, err := dkim.ParseDNSPublicKey(record)
if err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 5,
Detail: fmt.Sprintf("malformed DKIM public key at %s: %v", dnsHost, err)}
}
if err := dkim.Verify(pubDER, mc.RawMessage); err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 20,
Detail: fmt.Sprintf("DKIM signature verification failed (d=%s s=%s): %v", domain, selector, err)}
}
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Score: 0,
Detail: fmt.Sprintf("DKIM signature valid (d=%s s=%s)", domain, selector)}
}
+117
View File
@@ -0,0 +1,117 @@
package pipeline
import (
"context"
"fmt"
"net"
"strings"
"gomail/internal/db"
)
type DMARCStage struct{}
func (s *DMARCStage) Name() string { return "dmarc" }
func (s *DMARCStage) Run(ctx context.Context, mc *MailContext) *StageResult {
msg, err := mc.ParsedMessage()
if err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 3,
Detail: fmt.Sprintf("could not parse message headers: %v", err)}
}
fromHeader := msg.Header.Get("From")
fromDomain := extractDomainFromHeader(fromHeader)
if fromDomain == "" {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: "could not parse From header domain"}
}
// Alignment: does the RFC 5322 From domain match (or share an
// organisational domain with) the envelope MAIL FROM domain that SPF
// already checked? Misalignment is exactly what DMARC exists to catch —
// SPF/DKIM passing for a *different* domain than what the user sees in
// their inbox is a classic spoofing pattern.
envelopeDomain := mc.MailFromDomain()
aligned := envelopeDomain != "" && (fromDomain == envelopeDomain || orgDomain(fromDomain) == orgDomain(envelopeDomain))
resolver := net.DefaultResolver
txts, err := resolver.LookupTXT(ctx, "_dmarc."+fromDomain)
if err != nil || len(txts) == 0 {
// Fall back to organisational domain per RFC 7489 §6.6.3. A failure
// here just leaves txts empty, handled by the "no record found"
// check below — no separate error path needed.
org := orgDomain(fromDomain)
if org != fromDomain {
txts, _ = resolver.LookupTXT(ctx, "_dmarc."+org)
}
}
var record string
for _, txt := range txts {
if strings.HasPrefix(strings.ToLower(txt), "v=dmarc1") {
record = txt
break
}
}
if record == "" {
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5,
Detail: fmt.Sprintf("no DMARC record published for %s", fromDomain)}
}
policy := dmarcTag(record, "p")
detail := fmt.Sprintf("DMARC policy=%s for %s, envelope/header alignment=%v", policy, fromDomain, aligned)
if aligned {
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Score: 0, Detail: detail}
}
switch policy {
case "reject":
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 25, Detail: detail}
case "quarantine":
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 15, Detail: detail}
default: // "none" or unrecognised
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: detail}
}
}
func extractDomainFromHeader(headerValue string) string {
// RFC 5322 From can be "Name <addr@domain>" or bare "addr@domain".
addr := headerValue
if i := strings.Index(headerValue, "<"); i >= 0 {
if j := strings.Index(headerValue[i:], ">"); j >= 0 {
addr = headerValue[i+1 : i+j]
}
}
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(addr)), "@", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
// orgDomain approximates the "organisational domain" (RFC 7489 §3.2) by
// taking the last two labels — good enough for common TLDs (.com, .net,
// .org). It does not consult the Public Suffix List, so it will
// mis-identify the org domain for domains under multi-label public suffixes
// like .co.uk; that refinement can be added later without changing the
// stage's shape (it would only affect the fallback DNS lookup and the
// alignment comparison, both isolated to this one helper).
func orgDomain(domain string) string {
labels := strings.Split(domain, ".")
if len(labels) <= 2 {
return domain
}
return strings.Join(labels[len(labels)-2:], ".")
}
func dmarcTag(record, tag string) string {
for _, part := range strings.Split(record, ";") {
part = strings.TrimSpace(part)
name, value, found := strings.Cut(part, "=")
if found && strings.TrimSpace(name) == tag {
return strings.TrimSpace(value)
}
}
return ""
}
+80
View File
@@ -0,0 +1,80 @@
package pipeline
import (
"fmt"
"context"
"strings"
"gomail/internal/db"
)
type HeaderStage struct{}
func (s *HeaderStage) Name() string { return "headers" }
func (s *HeaderStage) Run(_ context.Context, mc *MailContext) *StageResult {
msg, err := mc.ParsedMessage()
if err != nil {
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 5,
Detail: fmt.Sprintf("could not parse headers: %v", err)}
}
var issues []string
score := 0.0
if msg.Header.Get("From") == "" {
issues = append(issues, "missing From header")
score += 15
}
if msg.Header.Get("Subject") == "" {
issues = append(issues, "missing Subject header")
score += 3
}
if msg.Header.Get("Date") == "" {
issues = append(issues, "missing Date header")
score += 5
}
fromDomain := extractDomainFromHeader(msg.Header.Get("From"))
envDomain := mc.MailFromDomain()
if fromDomain != "" && envDomain != "" && fromDomain != envDomain {
issues = append(issues, fmt.Sprintf("From header domain (%s) differs from envelope sender (%s)", fromDomain, envDomain))
score += 10
}
if replyTo := msg.Header.Get("Reply-To"); replyTo != "" {
replyDomain := extractDomainFromHeader(replyTo)
if replyDomain != "" && fromDomain != "" && replyDomain != fromDomain {
issues = append(issues, "Reply-To domain differs from From domain")
score += 8
}
}
subject := strings.ToLower(msg.Header.Get("Subject"))
urgencyPhrases := []string{
"urgent", "verify your account", "confirm your", "suspended",
"unusual activity", "act now", "immediately", "security alert",
}
for _, phrase := range urgencyPhrases {
if strings.Contains(subject, phrase) {
issues = append(issues, fmt.Sprintf("urgency language in subject: %q", phrase))
score += 4
break
}
}
result := db.CheckPass
if score > 0 {
result = db.CheckWarn
}
if score >= 20 {
result = db.CheckFail
}
detail := "no header issues found"
if len(issues) > 0 {
detail = strings.Join(issues, "; ")
}
return &StageResult{Stage: s.Name(), Result: result, Score: score, Detail: detail}
}
+159
View File
@@ -0,0 +1,159 @@
package pipeline
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gomail/internal/db"
)
// LLMStage asks a local LLM server for a spam/phishing judgment via the
// OpenAI-compatible /v1/chat/completions endpoint — what llama.cpp's
// server exposes (also what most other local-inference servers converged
// on), so no custom llama.cpp-specific protocol is needed. The model is
// instructed to answer with a single 0-100 integer, parsed directly with
// no JSON-mode/function-calling dependency, since not every local server
// build supports those reliably.
//
// This is deliberately a coarse signal, not a primary verdict: LLM output
// is non-deterministic and shouldn't singlehandedly quarantine mail, so
// its score contribution is capped lower than the deterministic stages
// (SPF/DKIM/DMARC) — see the capping in Run.
type LLMStage struct {
BaseURL string
Model string
Timeout time.Duration
}
func (s *LLMStage) Name() string { return "llm" }
const llmSystemPrompt = `You are a spam and phishing classifier. You will be given the headers and ` +
`body of an email. Respond with ONLY a single integer from 0 to 100 representing how likely ` +
`this email is to be spam, phishing, or malicious — 0 means definitely legitimate, 100 means ` +
`definitely malicious. Do not include any other text, explanation, or punctuation in your response.`
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatCompletionResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
}
// maxScoreContribution caps how much the LLM stage alone can push the
// total score, regardless of what the model returns — see the doc comment
// above for why.
const maxScoreContribution = 30.0
func (s *LLMStage) Run(ctx context.Context, mc *MailContext) *StageResult {
start := time.Now()
result := &StageResult{Stage: s.Name()}
content := mc.RawMessage
const maxContentBytes = 8192
if len(content) > maxContentBytes {
content = content[:maxContentBytes]
}
score, err := s.classify(ctx, string(content))
result.DurationMs = time.Since(start).Milliseconds()
if err != nil {
result.Result = db.CheckError
result.Detail = "LLM classification failed: " + err.Error()
return result
}
scaledScore := (score / 100.0) * maxScoreContribution
result.Score = scaledScore
result.Detail = fmt.Sprintf("LLM raw score=%.0f/100, capped contribution=%.1f", score, scaledScore)
if score >= 70 {
result.Result = db.CheckFail
} else if score >= 40 {
result.Result = db.CheckWarn
} else {
result.Result = db.CheckPass
}
return result
}
func (s *LLMStage) classify(ctx context.Context, content string) (float64, error) {
reqBody := chatCompletionRequest{
Model: s.Model,
Messages: []chatMessage{
{Role: "system", Content: llmSystemPrompt},
{Role: "user", Content: content},
},
}
bodyJSON, err := json.Marshal(reqBody)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.BaseURL+"/v1/chat/completions", bytes.NewReader(bodyJSON))
if err != nil {
return 0, fmt.Errorf("building request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: s.Timeout}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("request to LLM server: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("LLM server returned status %d", resp.StatusCode)
}
var result chatCompletionResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("parsing LLM response: %w", err)
}
if len(result.Choices) == 0 {
return 0, fmt.Errorf("LLM response had no choices")
}
raw := strings.TrimSpace(result.Choices[0].Message.Content)
digits := extractLeadingDigits(raw)
if digits == "" {
return 0, fmt.Errorf("LLM response did not contain a parseable score: %q", raw)
}
score, err := strconv.ParseFloat(digits, 64)
if err != nil {
return 0, fmt.Errorf("parsing score %q: %w", digits, err)
}
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
return score, nil
}
func extractLeadingDigits(s string) string {
var sb strings.Builder
for _, r := range s {
if r >= '0' && r <= '9' {
sb.WriteRune(r)
} else if sb.Len() > 0 {
break
}
}
return sb.String()
}
+92
View File
@@ -0,0 +1,92 @@
package pipeline
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"gomail/internal/db"
)
// RspamdStage submits the raw message to rspamd's documented /checkv2 HTTP
// API and maps its score/action into this pipeline's scoring model. No
// third-party rspamd client — a plain POST with the raw RFC 5322 message
// as the body is rspamd's actual documented interface. Off by default —
// only active when config.Pipeline.RspamdURL is set.
type RspamdStage struct {
BaseURL string // e.g. "http://127.0.0.1:11333"
Timeout time.Duration
}
func (s *RspamdStage) Name() string { return "rspamd" }
type rspamdResponse struct {
Score float64 `json:"score"`
RequiredScore float64 `json:"required_score"`
Action string `json:"action"`
Symbols map[string]rspamdSymbol `json:"symbols"`
}
type rspamdSymbol struct {
Score float64 `json:"score"`
Name string `json:"name"`
Options []string `json:"options,omitempty"`
}
func (s *RspamdStage) Run(ctx context.Context, mc *MailContext) *StageResult {
start := time.Now()
result := &StageResult{Stage: s.Name()}
resp, err := s.check(ctx, mc.RawMessage)
result.DurationMs = time.Since(start).Milliseconds()
if err != nil {
result.Result = db.CheckError
result.Detail = "rspamd check failed: " + err.Error()
return result
}
// Translate rspamd's own score onto this pipeline's scale by using its
// score directly — rspamd's score is already meant to be compared
// against thresholds the same way this pipeline's is, so no unit
// conversion trickery, just pass it through.
result.Score = resp.Score
switch resp.Action {
case "reject":
result.Result = db.CheckFail
case "add header", "rewrite subject", "greylist":
result.Result = db.CheckWarn
default:
result.Result = db.CheckPass
}
result.Detail = fmt.Sprintf("rspamd score=%.2f required=%.2f action=%s symbols=%d",
resp.Score, resp.RequiredScore, resp.Action, len(resp.Symbols))
return result
}
func (s *RspamdStage) check(ctx context.Context, raw []byte) (*rspamdResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.BaseURL+"/checkv2", bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
req.Header.Set("Content-Type", "message/rfc822")
client := &http.Client{Timeout: s.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request to rspamd: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("rspamd returned status %d", resp.StatusCode)
}
var result rspamdResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parsing rspamd response: %w", err)
}
return &result, nil
}
+162
View File
@@ -0,0 +1,162 @@
package pipeline
import (
"context"
"fmt"
"net"
"strings"
"gomail/internal/db"
)
type SPFStage struct{}
func (s *SPFStage) Name() string { return "spf" }
func (s *SPFStage) Run(ctx context.Context, mc *MailContext) *StageResult {
if mc.SenderIP == nil {
return &StageResult{Stage: s.Name(), Result: db.CheckError, Detail: "no sender IP available"}
}
domain := mc.MailFromDomain()
if domain == "" {
// Null sender (bounces, MAIL FROM:<>) — SPF simply doesn't apply.
return &StageResult{Stage: s.Name(), Result: db.CheckSkipped, Detail: "null sender, SPF not applicable"}
}
result, detail := checkSPF(ctx, mc.SenderIP, domain)
return &StageResult{Stage: s.Name(), Result: result.check, Score: result.score, Detail: detail}
}
type spfOutcome struct {
check db.CheckResult
score float64
}
func checkSPF(ctx context.Context, senderIP net.IP, domain string) (spfOutcome, string) {
resolver := net.DefaultResolver
txts, err := resolver.LookupTXT(ctx, domain)
if err != nil {
return spfOutcome{db.CheckWarn, 5}, fmt.Sprintf("SPF DNS lookup error for %s: %v", domain, err)
}
var spfRecord string
for _, txt := range txts {
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
spfRecord = txt
break
}
}
if spfRecord == "" {
return spfOutcome{db.CheckWarn, 8}, fmt.Sprintf("no SPF record published for %s", domain)
}
pass, reason := evaluateSPF(ctx, senderIP, domain, spfRecord, 0)
if pass {
return spfOutcome{db.CheckPass, 0}, fmt.Sprintf("SPF pass for %s (%s)", domain, reason)
}
switch {
case strings.Contains(spfRecord, "-all"):
return spfOutcome{db.CheckFail, 25}, fmt.Sprintf("SPF hard fail for %s: %s", domain, reason)
case strings.Contains(spfRecord, "~all"):
return spfOutcome{db.CheckWarn, 10}, fmt.Sprintf("SPF softfail for %s: %s", domain, reason)
default:
return spfOutcome{db.CheckWarn, 5}, fmt.Sprintf("SPF neutral/no-match for %s: %s", domain, reason)
}
}
// evaluateSPF is a pragmatic RFC 7208 evaluator: ip4/ip6/a/mx/include/redirect
// mechanisms, up to 10 levels of recursion (the spec's own limit). It does not
// implement every rarely-used mechanism (ptr, exists) — those are uncommon in
// modern SPF records and can be added later without changing the stage's shape.
func evaluateSPF(ctx context.Context, ip net.IP, domain, record string, depth int) (bool, string) {
if depth > 10 {
return false, "too many SPF redirects/includes"
}
resolver := net.DefaultResolver
for _, tok := range strings.Fields(record)[1:] { // skip "v=spf1"
lower := strings.ToLower(tok)
switch {
case lower == "+all" || lower == "all":
return true, "all"
case lower == "-all" || lower == "~all" || lower == "?all":
return false, "all (no earlier match)"
case strings.HasPrefix(lower, "ip4:"), strings.HasPrefix(lower, "ip6:"):
cidr := tok[strings.Index(tok, ":")+1:]
if matchCIDR(ip, cidr) {
return true, "matched " + tok
}
case strings.HasPrefix(lower, "include:"):
incDomain := tok[len("include:"):]
txts, err := resolver.LookupTXT(ctx, incDomain)
if err == nil {
for _, txt := range txts {
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
if ok, r := evaluateSPF(ctx, ip, incDomain, txt, depth+1); ok {
return true, "include:" + incDomain + " -> " + r
}
break
}
}
}
case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"):
checkDomain := domain
if strings.HasPrefix(lower, "a:") {
checkDomain = tok[len("a:"):]
}
addrs, err := resolver.LookupHost(ctx, checkDomain)
if err == nil {
for _, a := range addrs {
if net.ParseIP(a).Equal(ip) {
return true, "matched a:" + checkDomain
}
}
}
case lower == "mx" || strings.HasPrefix(lower, "mx:"):
checkDomain := domain
if strings.HasPrefix(lower, "mx:") {
checkDomain = tok[len("mx:"):]
}
mxs, err := resolver.LookupMX(ctx, checkDomain)
if err == nil {
for _, mx := range mxs {
addrs, _ := resolver.LookupHost(ctx, mx.Host)
for _, a := range addrs {
if net.ParseIP(a).Equal(ip) {
return true, "matched mx:" + checkDomain
}
}
}
}
case strings.HasPrefix(lower, "redirect="):
redir := tok[len("redirect="):]
txts, err := resolver.LookupTXT(ctx, redir)
if err == nil {
for _, txt := range txts {
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
return evaluateSPF(ctx, ip, redir, txt, depth+1)
}
}
}
}
}
return false, "no mechanism matched"
}
func matchCIDR(ip net.IP, cidr string) bool {
if !strings.Contains(cidr, "/") {
return net.ParseIP(cidr).Equal(ip)
}
_, network, err := net.ParseCIDR(cidr)
if err != nil {
return false
}
return network.Contains(ip)
}
+99
View File
@@ -0,0 +1,99 @@
package pipeline
import (
"context"
"fmt"
"regexp"
"strings"
"gomail/internal/db"
)
type URLStage struct{}
func (s *URLStage) Name() string { return "urls" }
var urlRE = regexp.MustCompile(`https?://[^\s<>"']+`)
var shortenerDomains = []string{
"bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd", "buff.ly", "short.link",
}
var suspiciousTLDs = []string{
".xyz", ".top", ".click", ".work", ".loan", ".gq", ".tk", ".ml",
}
func (s *URLStage) Run(_ context.Context, mc *MailContext) *StageResult {
text := string(mc.RawMessage)
urls := urlRE.FindAllString(text, 50)
if len(urls) == 0 {
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Detail: "no URLs found"}
}
var issues []string
score := 0.0
seen := map[string]bool{}
for _, u := range urls {
u = strings.TrimRight(u, ".,;:!?)'\"")
if seen[u] {
continue
}
seen[u] = true
lower := strings.ToLower(u)
for _, shortener := range shortenerDomains {
if strings.Contains(lower, shortener) {
issues = append(issues, fmt.Sprintf("URL shortener: %s", shortener))
score += 6
break
}
}
for _, tld := range suspiciousTLDs {
if strings.Contains(lower, tld) {
issues = append(issues, fmt.Sprintf("suspicious TLD in URL: %s", u))
score += 4
break
}
}
// IP-address-literal URLs (http://1.2.3.4/...) are a strong phishing
// signal — legitimate mail almost never links directly to a bare IP.
if ipLiteralRE.MatchString(u) {
issues = append(issues, fmt.Sprintf("IP-literal URL: %s", u))
score += 8
}
}
if score > 30 {
score = 30 // cap — URL heuristics alone shouldn't dominate the verdict
}
result := db.CheckPass
if score > 0 {
result = db.CheckWarn
}
if score >= 15 {
result = db.CheckFail
}
detail := fmt.Sprintf("%d unique URL(s) found", len(seen))
if len(issues) > 0 {
detail += ": " + strings.Join(dedupe(issues), "; ")
}
return &StageResult{Stage: s.Name(), Result: result, Score: score, Detail: detail}
}
var ipLiteralRE = regexp.MustCompile(`https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`)
func dedupe(items []string) []string {
seen := map[string]bool{}
var out []string
for _, i := range items {
if !seen[i] {
seen[i] = true
out = append(out, i)
}
}
return out
}