Skip to content

Commit

Permalink
Feature: Add optional SpamAssassin integration to display scores (#233)
Browse files Browse the repository at this point in the history
  • Loading branch information
axllent committed Jan 19, 2024
1 parent 9a63567 commit 9cda71f
Show file tree
Hide file tree
Showing 15 changed files with 1,013 additions and 5 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ including image thumbnails), including optional [HTTPS](https://mailpit.axllent.
- Optional [basic authentication](https://mailpit.axllent.org/docs/configuration/frontend-authentication/) for web UI & API
- [HTML check](https://mailpit.axllent.org/docs/usage/html-check/) to test & score mail client compatibility with HTML emails
- [Link check](https://mailpit.axllent.org/docs/usage/link-check/) to test message links (HTML & text) & linked images
- [Spam check](https://mailpit.axllent.org/docs/usage/spamassassin/) to test message "spamminess" using a running SpamAssassin server
- [Create screenshots](https://mailpit.axllent.org/docs/usage/html-screenshots/) of HTML messages via web UI
- Mobile and tablet HTML preview toggle in desktop mode
- Advanced [mail search](https://mailpit.axllent.org/docs/usage/search-filters/)
Expand Down
4 changes: 4 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func init() {
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
rootCmd.Flags().BoolVar(&config.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
rootCmd.Flags().StringVar(&config.EnableSpamAssassin, "enable-spamassassin", config.EnableSpamAssassin, "Enable integration with SpamAssassin")

rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI & API authentication")
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-tls-cert", config.UITLSCert, "TLS certificate for web UI (HTTPS) - requires ui-tls-key")
Expand Down Expand Up @@ -208,6 +209,9 @@ func initConfigFromEnv() {
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
config.BlockRemoteCSSAndFonts = true
}
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
}
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
config.AllowUntrustedTLS = true
}
Expand Down
14 changes: 14 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -106,6 +107,9 @@ var (
// Use with extreme caution!
SMTPRelayAllIncoming = false

// EnableSpamAssassin must be either <host>:<port> or "postmark"
EnableSpamAssassin string

// WebhookURL for calling
WebhookURL string

Expand Down Expand Up @@ -245,6 +249,16 @@ func VerifyConfig() error {
return fmt.Errorf("Webhook URL does not appear to be a valid URL (%s)", WebhookURL)
}

if EnableSpamAssassin != "" {
spamassassin.SetService(EnableSpamAssassin)
logger.Log().Infof("[spamassassin] enabled via %s", EnableSpamAssassin)

if err := spamassassin.Ping(); err != nil {
logger.Log().Warnf("[spamassassin] ping: %s", err.Error())
} else {
}
}

SMTPTags = []AutoTag{}

if SMTPCLITags != "" {
Expand Down
100 changes: 100 additions & 0 deletions internal/spamassassin/postmark/postmark.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Package postmark uses the free https://spamcheck.postmarkapp.com/
// See https://spamcheck.postmarkapp.com/doc/ for more details.
package postmark

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
)

// Response struct
type Response struct {
Success bool `json:"success"`
Message string `json:"message"` // for errors only
Score string `json:"score"`
Rules []Rule `json:"rules"`
Report string `json:"report"` // ignored
}

// Rule struct
type Rule struct {
Score string `json:"score"`
// Name not returned by postmark but rather extracted from description
Name string `json:"name"`
Description string `json:"description"`
}

// Check will post the email data to Postmark
func Check(email []byte, timeout int) (Response, error) {
r := Response{}
// '{"email":"raw dump of email", "options":"short"}'
var d struct {
// The raw dump of the email to be filtered, including all headers.
Email string `json:"email"`
// Default "long". Must either be "long" for a full report of processing rules, or "short" for a score request.
Options string `json:"options"`
}

d.Email = string(email)
d.Options = "long"

data, err := json.Marshal(d)
if err != nil {
return r, err
}

client := http.Client{
Timeout: time.Duration(timeout) * time.Second,
}

resp, err := client.Post("https://spamcheck.postmarkapp.com/filter", "application/json",
bytes.NewBuffer(data))

if err != nil {
return r, err
}

defer resp.Body.Close()

err = json.NewDecoder(resp.Body).Decode(&r)

// remove trailing line spaces for all lines in report
re := regexp.MustCompile("\r?\n")
lines := re.Split(r.Report, -1)
reportLines := []string{}
for _, l := range lines {
line := strings.TrimRight(l, " ")
reportLines = append(reportLines, line)
}
reportRaw := strings.Join(reportLines, "\n")

// join description lines to make a single line per rule
re2 := regexp.MustCompile("\n ")
report := re2.ReplaceAllString(reportRaw, "")
for i, rule := range r.Rules {
// populate rule name
r.Rules[i].Name = nameFromReport(rule.Score, rule.Description, report)
}

return r, err
}

// Extract the name of the test from the report as Postmark does not include this in the JSON reports
func nameFromReport(score, description, report string) string {
score = regexp.QuoteMeta(score)
description = regexp.QuoteMeta(description)
str := fmt.Sprintf("%s\\s+([A-Z0-9\\_]+)\\s+%s", score, description)
re := regexp.MustCompile(str)

matches := re.FindAllStringSubmatch(report, 1)
if len(matches) > 0 && len(matches[0]) == 2 {
return strings.TrimSpace(matches[0][1])
}

return ""
}
147 changes: 147 additions & 0 deletions internal/spamassassin/spamassassin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Package spamassassin will return results from either a SpamAssassin server or
// Postmark's public API depending on configuration
package spamassassin

import (
"errors"
"math"
"strconv"
"strings"

"github.com/axllent/mailpit/internal/spamassassin/postmark"
"github.com/axllent/mailpit/internal/spamassassin/spamc"
)

var (
// Service to use, either "<host>:<ip>" for self-hosted SpamAssassin or "postmark"
service string

// SpamScore is the score at which a message is determined to be spam
spamScore = 5.0

// Timeout in seconds
timeout = 8
)

// Result is a SpamAssassin result
//
// swagger:model SpamAssassinResponse
type Result struct {
// Whether the message is spam or not
IsSpam bool
// If populated will return an error string
Error string
// Total spam score based on triggered rules
Score float64
// Spam rules triggered
Rules []Rule
}

// Rule struct
type Rule struct {
// Spam rule score
Score float64
// SpamAssassin rule name
Name string
// SpamAssassin rule description
Description string
}

// SetService defines which service should be used.
func SetService(s string) {
switch s {
case "postmark":
service = "postmark"
default:
service = s
}
}

// SetTimeout defines the timeout
func SetTimeout(t int) {
if t > 0 {
timeout = t
}
}

// Ping returns whether a service is active or not
func Ping() error {
if service == "postmark" {
return nil
}

var client *spamc.Client
if strings.HasPrefix("unix:", service) {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
}

return client.Ping()
}

// Check will return a Result
func Check(msg []byte) (Result, error) {
r := Result{Score: 0}

if service == "" {
return r, errors.New("no SpamAssassin service defined")
}

if service == "postmark" {
res, err := postmark.Check(msg, timeout)
if err != nil {
r.Error = err.Error()
return r, nil
}
resFloat, err := strconv.ParseFloat(res.Score, 32)
if err == nil {
r.Score = round1dm(resFloat)
r.IsSpam = resFloat >= spamScore
}
r.Error = res.Message
for _, pr := range res.Rules {
rule := Rule{}
value, err := strconv.ParseFloat(pr.Score, 32)
if err == nil {
rule.Score = round1dm(value)
}
rule.Name = pr.Name
rule.Description = pr.Description
r.Rules = append(r.Rules, rule)
}
} else {
var client *spamc.Client
if strings.HasPrefix("unix:", service) {
client = spamc.NewUnix(strings.TrimLeft(service, "unix:"))
} else {
client = spamc.NewTCP(service, timeout)
}

res, err := client.Report(msg)
if err != nil {
r.Error = err.Error()
return r, nil
}
r.IsSpam = res.Score >= spamScore
r.Score = round1dm(res.Score)
r.Rules = []Rule{}
for _, sr := range res.Rules {
rule := Rule{}
value, err := strconv.ParseFloat(sr.Points, 32)
if err == nil {
rule.Score = round1dm(value)
}
rule.Name = sr.Name
rule.Description = sr.Description
r.Rules = append(r.Rules, rule)
}
}

return r, nil
}

// Round to one decimal place
func round1dm(n float64) float64 {
return math.Floor(n*10) / 10
}
Loading

0 comments on commit 9cda71f

Please sign in to comment.