From 9cda71f21af9314e802435f0a4257b93cef33e2c Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 20 Jan 2024 12:05:56 +1300 Subject: [PATCH] Feature: Add optional SpamAssassin integration to display scores (#233) --- README.md | 1 + cmd/root.go | 4 + config/config.go | 14 + internal/spamassassin/postmark/postmark.go | 100 ++++++ internal/spamassassin/spamassassin.go | 147 +++++++++ internal/spamassassin/spamc/spamc.go | 245 ++++++++++++++ server/apiv1/api.go | 51 +++ server/apiv1/structs.go | 4 + server/apiv1/swagger.go | 10 + server/apiv1/webui.go | 4 + server/server.go | 3 + server/ui-src/assets/styles.scss | 6 + server/ui-src/components/message/Message.vue | 41 ++- .../components/message/SpamAssassin.vue | 299 ++++++++++++++++++ server/ui/api/v1/swagger.json | 89 ++++++ 15 files changed, 1013 insertions(+), 5 deletions(-) create mode 100644 internal/spamassassin/postmark/postmark.go create mode 100644 internal/spamassassin/spamassassin.go create mode 100644 internal/spamassassin/spamc/spamc.go create mode 100644 server/ui-src/components/message/SpamAssassin.vue diff --git a/README.md b/README.md index d98b990..db79373 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/cmd/root.go b/cmd/root.go index 03a3878..4a98a13 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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") @@ -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 } diff --git a/config/config.go b/config/config.go index 15aa286..1694011 100644 --- a/config/config.go +++ b/config/config.go @@ -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" ) @@ -106,6 +107,9 @@ var ( // Use with extreme caution! SMTPRelayAllIncoming = false + // EnableSpamAssassin must be either : or "postmark" + EnableSpamAssassin string + // WebhookURL for calling WebhookURL string @@ -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 != "" { diff --git a/internal/spamassassin/postmark/postmark.go b/internal/spamassassin/postmark/postmark.go new file mode 100644 index 0000000..854a232 --- /dev/null +++ b/internal/spamassassin/postmark/postmark.go @@ -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 "" +} diff --git a/internal/spamassassin/spamassassin.go b/internal/spamassassin/spamassassin.go new file mode 100644 index 0000000..57f0cc9 --- /dev/null +++ b/internal/spamassassin/spamassassin.go @@ -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 ":" 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 +} diff --git a/internal/spamassassin/spamc/spamc.go b/internal/spamassassin/spamc/spamc.go new file mode 100644 index 0000000..195fc52 --- /dev/null +++ b/internal/spamassassin/spamc/spamc.go @@ -0,0 +1,245 @@ +// Package spamc provides a client for the SpamAssassin spamd protocol. +// http://svn.apache.org/repos/asf/spamassassin/trunk/spamd/PROTOCOL +// +// Modified to add timeouts from https://github.com/cgt/spamc +package spamc + +import ( + "bufio" + "fmt" + "io" + "net" + "regexp" + "strconv" + "strings" + "time" +) + +// ProtoVersion is the protocol version +const ProtoVersion = "1.5" + +var ( + spamInfoRe = regexp.MustCompile(`(.+)\/(.+) (\d+) (.+)`) + spamMainRe = regexp.MustCompile(`^Spam: (.+) ; (.+) . (.+)$`) + spamDetailsRe = regexp.MustCompile(`^\s?(-?[0-9\.]+)\s([a-zA-Z0-9_]*)(\W*)(.*)`) +) + +// connection is like net.Conn except that it also has a CloseWrite method. +// CloseWrite is implemented by net.TCPConn and net.UnixConn, but for some +// reason it is not present in the net.Conn interface. +type connection interface { + net.Conn + CloseWrite() error +} + +// Client is a spamd client. +type Client struct { + net string + addr string + timeout int +} + +// NewTCP returns a *Client that connects to spamd via the given TCP address. +func NewTCP(addr string, timeout int) *Client { + return &Client{"tcp", addr, timeout} +} + +// NewUnix returns a *Client that connects to spamd via the given Unix socket. +func NewUnix(addr string) *Client { + return &Client{"unix", addr, 0} +} + +// Rule represents a matched SpamAssassin rule. +type Rule struct { + Points string + Name string + Description string +} + +// Result struct +type Result struct { + ResponseCode int + Message string + Spam bool + Score float64 + Threshold float64 + Rules []Rule +} + +// dial connects to spamd through TCP or a Unix socket. +func (c *Client) dial() (connection, error) { + if c.net == "tcp" { + tcpAddr, err := net.ResolveTCPAddr("tcp", c.addr) + if err != nil { + return nil, err + } + return net.DialTCP("tcp", nil, tcpAddr) + } else if c.net == "unix" { + unixAddr, err := net.ResolveUnixAddr("unix", c.addr) + if err != nil { + return nil, err + } + return net.DialUnix("unix", nil, unixAddr) + } + panic("Client.net must be either \"tcp\" or \"unix\"") +} + +// Report checks if message is spam or not, and returns score plus report +func (c *Client) Report(email []byte) (Result, error) { + output, err := c.report(email) + if err != nil { + return Result{}, err + } + + return c.parseOutput(output), nil +} + +func (c *Client) report(email []byte) ([]string, error) { + conn, err := c.dial() + if err != nil { + return nil, err + } + + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil { + return nil, err + } + + bw := bufio.NewWriter(conn) + _, err = bw.WriteString("REPORT SPAMC/" + ProtoVersion + "\r\n") + if err != nil { + return nil, err + } + _, err = bw.WriteString("Content-length: " + strconv.Itoa(len(email)) + "\r\n\r\n") + if err != nil { + return nil, err + } + _, err = bw.Write(email) + if err != nil { + return nil, err + } + err = bw.Flush() + if err != nil { + return nil, err + } + // Client is supposed to close its writing side of the connection + // after sending its request. + err = conn.CloseWrite() + if err != nil { + return nil, err + } + + var ( + lines []string + br = bufio.NewReader(conn) + ) + for { + line, err := br.ReadString('\n') + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + line = strings.TrimRight(line, " \t\r\n") + lines = append(lines, line) + } + + // join lines, and replace multi-line descriptions with single line for each + tmp := strings.Join(lines, "\n") + re := regexp.MustCompile("\n ") + n := re.ReplaceAllString(tmp, " ") + + //split lines again + return strings.Split(n, "\n"), nil +} + +func (c *Client) parseOutput(output []string) Result { + var result Result + var reachedRules bool + for _, row := range output { + // header + if spamInfoRe.MatchString(row) { + res := spamInfoRe.FindStringSubmatch(row) + if len(res) == 5 { + resCode, err := strconv.Atoi(res[3]) + if err == nil { + result.ResponseCode = resCode + } + result.Message = res[4] + continue + } + } + // summary + if spamMainRe.MatchString(row) { + res := spamMainRe.FindStringSubmatch(row) + if len(res) == 4 { + if strings.ToLower(res[1]) == "true" || strings.ToLower(res[1]) == "yes" { + result.Spam = true + } else { + result.Spam = false + } + resFloat, err := strconv.ParseFloat(res[2], 32) + if err == nil { + result.Score = resFloat + continue + } + resFloat, err = strconv.ParseFloat(res[3], 32) + if err == nil { + result.Threshold = resFloat + continue + } + } + } + + if strings.HasPrefix(row, "Content analysis details") { + reachedRules = true + continue + } + // details + // row = strings.Trim(row, " \t\r\n") + if reachedRules && spamDetailsRe.MatchString(row) { + res := spamDetailsRe.FindStringSubmatch(row) + if len(res) == 5 { + rule := Rule{Points: res[1], Name: res[2], Description: res[4]} + result.Rules = append(result.Rules, rule) + } + } + } + return result +} + +// Ping the spamd +func (c *Client) Ping() error { + conn, err := c.dial() + if err != nil { + return err + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(time.Duration(c.timeout) * time.Second)); err != nil { + return err + } + + _, err = io.WriteString(conn, fmt.Sprintf("PING SPAMC/%s\r\n\r\n", ProtoVersion)) + if err != nil { + return err + } + err = conn.CloseWrite() + if err != nil { + return err + } + + br := bufio.NewReader(conn) + for { + _, err = br.ReadSlice('\n') + if err == io.EOF { + break + } + if err != nil { + return err + } + } + return nil +} diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 533a6fc..453592e 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -15,6 +15,7 @@ import ( "github.com/axllent/mailpit/internal/htmlcheck" "github.com/axllent/mailpit/internal/linkcheck" "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/spamassassin" "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/server/smtpd" @@ -821,6 +822,56 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(bytes) } +// SpamAssassinCheck returns a summary of SpamAssassin results (if enabled) +func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) { + // swagger:route GET /api/v1/message/{ID}/sa-check Other SpamAssassinCheck + // + // # SpamAssassin check (beta) + // + // Returns the SpamAssassin (if enabled) summary of the message. + // + // NOTE: This feature is currently in beta and is documented for reference only. + // Please do not integrate with it (yet) as there may be changes. + // + // Produces: + // - application/json + // + // Schemes: http, https + // + // Responses: + // 200: SpamAssassinResponse + // default: ErrorResponse + + vars := mux.Vars(r) + id := vars["id"] + + if id == "latest" { + var err error + id, err = storage.LatestID(r) + if err != nil { + w.WriteHeader(404) + fmt.Fprint(w, err.Error()) + return + } + } + + msg, err := storage.GetMessageRaw(id) + if err != nil { + fourOFour(w) + return + } + + summary, err := spamassassin.Check(msg) + if err != nil { + httpError(w, err.Error()) + return + } + + bytes, _ := json.Marshal(summary) + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write(bytes) +} + // FourOFour returns a basic 404 message func fourOFour(w http.ResponseWriter) { w.Header().Set("Referrer-Policy", "no-referrer") diff --git a/server/apiv1/structs.go b/server/apiv1/structs.go index 8bf1290..dfe9e0a 100644 --- a/server/apiv1/structs.go +++ b/server/apiv1/structs.go @@ -3,6 +3,7 @@ package apiv1 import ( "github.com/axllent/mailpit/internal/htmlcheck" "github.com/axllent/mailpit/internal/linkcheck" + "github.com/axllent/mailpit/internal/spamassassin" "github.com/axllent/mailpit/internal/storage" ) @@ -50,3 +51,6 @@ type HTMLCheckResponse = htmlcheck.Response // LinkCheckResponse summary type LinkCheckResponse = linkcheck.Response + +// SpamAssassinResponse summary +type SpamAssassinResponse = spamassassin.Result diff --git a/server/apiv1/swagger.go b/server/apiv1/swagger.go index 7bdd36a..8dffcf0 100644 --- a/server/apiv1/swagger.go +++ b/server/apiv1/swagger.go @@ -146,6 +146,16 @@ type linkCheckParams struct { Follow string `json:"follow"` } +// swagger:parameters SpamAssassinCheck +type spamAssassinCheckParams struct { + // Message database ID or "latest" + // + // in: path + // description: Message database ID or "latest" + // required: true + ID string +} + // Binary data response inherits the attachment's content type // swagger:response BinaryResponse type binaryResponse string diff --git a/server/apiv1/webui.go b/server/apiv1/webui.go index a7ac0cd..d5a79aa 100644 --- a/server/apiv1/webui.go +++ b/server/apiv1/webui.go @@ -26,6 +26,9 @@ type webUIConfiguration struct { // Whether the HTML check has been globally disabled DisableHTMLCheck bool + + // Whether SpamAssassin is enabled + SpamAssassin bool } // WebUIConfig returns configuration settings for the web UI. @@ -55,6 +58,7 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) { } conf.DisableHTMLCheck = config.DisableHTMLCheck + conf.SpamAssassin = config.EnableSpamAssassin != "" bytes, _ := json.Marshal(conf) diff --git a/server/server.go b/server/server.go index a91c686..7f601d8 100644 --- a/server/server.go +++ b/server/server.go @@ -123,6 +123,9 @@ func apiRoutes() *mux.Router { r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET") } r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).Methods("GET") + if config.EnableSpamAssassin != "" { + r.HandleFunc(config.Webroot+"api/v1/message/{id}/sa-check", middleWareFunc(apiv1.SpamAssassinCheck)).Methods("GET") + } r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET") r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET") diff --git a/server/ui-src/assets/styles.scss b/server/ui-src/assets/styles.scss index 44593d3..844817c 100644 --- a/server/ui-src/assets/styles.scss +++ b/server/ui-src/assets/styles.scss @@ -319,6 +319,12 @@ body.blur { } } +.dropdown-menu.checks { + .dropdown-item { + min-width: 190px; + } +} + // bootstrap5-tags .tags-badge { display: flex; diff --git a/server/ui-src/components/message/Message.vue b/server/ui-src/components/message/Message.vue index eb2c67a..16c6b29 100644 --- a/server/ui-src/components/message/Message.vue +++ b/server/ui-src/components/message/Message.vue @@ -1,9 +1,10 @@ + + diff --git a/server/ui/api/v1/swagger.json b/server/ui/api/v1/swagger.json index f1c61a3..b49e4ca 100644 --- a/server/ui/api/v1/swagger.json +++ b/server/ui/api/v1/swagger.json @@ -366,6 +366,43 @@ } } }, + "/api/v1/message/{ID}/sa-check": { + "get": { + "description": "Returns the SpamAssassin (if enabled) summary of the message.\n\nNOTE: This feature is currently in beta and is documented for reference only.\nPlease do not integrate with it (yet) as there may be changes.", + "produces": [ + "application/json" + ], + "schemes": [ + "http", + "https" + ], + "tags": [ + "Other" + ], + "summary": "SpamAssassin check (beta)", + "operationId": "SpamAssassinCheck", + "parameters": [ + { + "type": "string", + "description": "Message database ID or \"latest\"", + "name": "ID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "SpamAssassinResponse", + "schema": { + "$ref": "#/definitions/SpamAssassinResponse" + } + }, + "default": { + "$ref": "#/responses/ErrorResponse" + } + } + } + }, "/api/v1/messages": { "get": { "description": "Returns messages from the mailbox ordered from newest to oldest.", @@ -1299,6 +1336,54 @@ }, "x-go-package": "github.com/axllent/mailpit/server/apiv1" }, + "Rule": { + "description": "Rule struct", + "type": "object", + "properties": { + "Description": { + "description": "SpamAssassin rule description", + "type": "string" + }, + "Name": { + "description": "SpamAssassin rule name", + "type": "string" + }, + "Score": { + "description": "Spam rule score", + "type": "number", + "format": "double" + } + }, + "x-go-package": "github.com/axllent/mailpit/internal/spamassassin" + }, + "SpamAssassinResponse": { + "description": "Result is a SpamAssassin result", + "type": "object", + "properties": { + "Error": { + "description": "If populated will return an error string", + "type": "string" + }, + "IsSpam": { + "description": "Whether the message is spam or not", + "type": "boolean" + }, + "Rules": { + "description": "Spam rules triggered", + "type": "array", + "items": { + "$ref": "#/definitions/Rule" + } + }, + "Score": { + "description": "Total spam score based on triggered rules", + "type": "number", + "format": "double" + } + }, + "x-go-name": "Result", + "x-go-package": "github.com/axllent/mailpit/internal/spamassassin" + }, "WebUIConfiguration": { "description": "Response includes global web UI settings", "type": "object", @@ -1328,6 +1413,10 @@ "type": "string" } } + }, + "SpamAssassin": { + "description": "Whether SpamAssassin is enabled", + "type": "boolean" } }, "x-go-name": "webUIConfiguration",