#12: implement Antispam & Antivirus scanning pipeline (Rspamd & ClamAV)

This commit is contained in:
Lucas Rayan committed 2026-07-25 10:25:52 -03:00
1 parent 4c2e0b9563
commit 54d1ea5810
15 files changed
+825 -5

No files matched your search

@@ -0,0 +1,102 @@
package rspamd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
type RspamdResponse struct {
Action string `json:"action"`
Score float64 `json:"score"`
RequiredScore float64 `json:"required_score"`
Symbols map[string]map[string]interface{} `json:"symbols"`
}
type RspamdAdapter struct {
baseURL string
httpClient *http.Client
}
func NewRspamdAdapter(baseURL string) *RspamdAdapter {
if baseURL == "" {
baseURL = "http://localhost:11333"
}
return &RspamdAdapter{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{},
}
}
func (a *RspamdAdapter) Scan(ctx context.Context, input port.ScanInput) (*valueobject.ScanResult, error) {
url := fmt.Sprintf("%s/checkv2", a.baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(input.Payload))
if err != nil {
return nil, fmt.Errorf("create rspamd request: %w", err)
}
if input.ClientIP != "" {
req.Header.Set("IP", input.ClientIP)
}
if input.HeloDomain != "" {
req.Header.Set("Helo", input.HeloDomain)
}
if input.Sender != "" {
req.Header.Set("From", input.Sender)
}
if input.Recipient != "" {
req.Header.Set("Deliver-To", input.Recipient)
}
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("rspamd http post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("rspamd returned HTTP %d", resp.StatusCode)
}
var parsed RspamdResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, fmt.Errorf("decode rspamd json: %w", err)
}
res := &valueobject.ScanResult{
Score: parsed.Score,
RequiredScore: parsed.RequiredScore,
Action: parsed.Action,
Symbols: make(map[string]float64),
HeadersToAdd: make(map[string]string),
}
for name, obj := range parsed.Symbols {
if scoreVal, ok := obj["score"].(float64); ok {
res.Symbols[name] = scoreVal
}
}
switch parsed.Action {
case "reject":
res.Verdict = valueobject.ScanVerdictSpamReject
case "greylist":
res.Verdict = valueobject.ScanVerdictGreylist
case "add header", "rewrite subject":
res.Verdict = valueobject.ScanVerdictSpamJunk
res.HeadersToAdd["X-Spam-Flag"] = "YES"
res.HeadersToAdd["X-Spam-Score"] = fmt.Sprintf("%.2f", parsed.Score)
default:
res.Verdict = valueobject.ScanVerdictClean
res.HeadersToAdd["X-Spam-Flag"] = "NO"
res.HeadersToAdd["X-Spam-Score"] = fmt.Sprintf("%.2f", parsed.Score)
}
return res, nil
}
@@ -0,0 +1,54 @@
package rspamd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
func TestRspamdAdapter_Scan_MapsActions(t *testing.T) {
tests := []struct {
rspamdAction string
wantVerdict valueobject.ScanVerdict
}{
{"no action", valueobject.ScanVerdictClean},
{"add header", valueobject.ScanVerdictSpamJunk},
{"greylist", valueobject.ScanVerdictGreylist},
{"reject", valueobject.ScanVerdictSpamReject},
}
for _, tt := range tests {
t.Run(tt.rspamdAction, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/checkv2" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
resp := RspamdResponse{
Action: tt.rspamdAction,
Score: 5.0,
RequiredScore: 15.0,
Symbols: map[string]map[string]interface{}{
"SPF_PASS": {"score": -1.0},
},
}
json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
adapter := NewRspamdAdapter(ts.URL)
res, err := adapter.Scan(context.Background(), port.ScanInput{Payload: []byte("test email")})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if res.Verdict != tt.wantVerdict {
t.Errorf("verdict = %s, want %s", res.Verdict, tt.wantVerdict)
}
})
}
}