mirror of
https://github.com/axllent/mailpit.git
synced 2025-06-15 00:05:15 +02:00
Feature: Add optional SpamAssassin integration to display scores (#233)
This commit is contained in:
@ -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
|
- 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
|
- [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
|
- [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
|
- [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
|
- Mobile and tablet HTML preview toggle in desktop mode
|
||||||
- Advanced [mail search](https://mailpit.axllent.org/docs/usage/search-filters/)
|
- Advanced [mail search](https://mailpit.axllent.org/docs/usage/search-filters/)
|
||||||
|
@ -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.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.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().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.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")
|
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") {
|
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
|
||||||
config.BlockRemoteCSSAndFonts = true
|
config.BlockRemoteCSSAndFonts = true
|
||||||
}
|
}
|
||||||
|
if len(os.Getenv("MP_ENABLE_SPAMASSASSIN")) > 0 {
|
||||||
|
config.EnableSpamAssassin = os.Getenv("MP_ENABLE_SPAMASSASSIN")
|
||||||
|
}
|
||||||
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
|
if getEnabledFromEnv("MP_ALLOW_UNTRUSTED_TLS") {
|
||||||
config.AllowUntrustedTLS = true
|
config.AllowUntrustedTLS = true
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/axllent/mailpit/internal/auth"
|
"github.com/axllent/mailpit/internal/auth"
|
||||||
"github.com/axllent/mailpit/internal/logger"
|
"github.com/axllent/mailpit/internal/logger"
|
||||||
|
"github.com/axllent/mailpit/internal/spamassassin"
|
||||||
"github.com/axllent/mailpit/internal/tools"
|
"github.com/axllent/mailpit/internal/tools"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@ -106,6 +107,9 @@ var (
|
|||||||
// Use with extreme caution!
|
// Use with extreme caution!
|
||||||
SMTPRelayAllIncoming = false
|
SMTPRelayAllIncoming = false
|
||||||
|
|
||||||
|
// EnableSpamAssassin must be either <host>:<port> or "postmark"
|
||||||
|
EnableSpamAssassin string
|
||||||
|
|
||||||
// WebhookURL for calling
|
// WebhookURL for calling
|
||||||
WebhookURL string
|
WebhookURL string
|
||||||
|
|
||||||
@ -245,6 +249,16 @@ func VerifyConfig() error {
|
|||||||
return fmt.Errorf("Webhook URL does not appear to be a valid URL (%s)", WebhookURL)
|
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{}
|
SMTPTags = []AutoTag{}
|
||||||
|
|
||||||
if SMTPCLITags != "" {
|
if SMTPCLITags != "" {
|
||||||
|
100
internal/spamassassin/postmark/postmark.go
Normal file
100
internal/spamassassin/postmark/postmark.go
Normal file
@ -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
internal/spamassassin/spamassassin.go
Normal file
147
internal/spamassassin/spamassassin.go
Normal file
@ -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
|
||||||
|
}
|
245
internal/spamassassin/spamc/spamc.go
Normal file
245
internal/spamassassin/spamc/spamc.go
Normal file
@ -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
|
||||||
|
}
|
@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||||
"github.com/axllent/mailpit/internal/linkcheck"
|
"github.com/axllent/mailpit/internal/linkcheck"
|
||||||
"github.com/axllent/mailpit/internal/logger"
|
"github.com/axllent/mailpit/internal/logger"
|
||||||
|
"github.com/axllent/mailpit/internal/spamassassin"
|
||||||
"github.com/axllent/mailpit/internal/storage"
|
"github.com/axllent/mailpit/internal/storage"
|
||||||
"github.com/axllent/mailpit/internal/tools"
|
"github.com/axllent/mailpit/internal/tools"
|
||||||
"github.com/axllent/mailpit/server/smtpd"
|
"github.com/axllent/mailpit/server/smtpd"
|
||||||
@ -821,6 +822,56 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(bytes)
|
_, _ = 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
|
// FourOFour returns a basic 404 message
|
||||||
func fourOFour(w http.ResponseWriter) {
|
func fourOFour(w http.ResponseWriter) {
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
@ -3,6 +3,7 @@ package apiv1
|
|||||||
import (
|
import (
|
||||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||||
"github.com/axllent/mailpit/internal/linkcheck"
|
"github.com/axllent/mailpit/internal/linkcheck"
|
||||||
|
"github.com/axllent/mailpit/internal/spamassassin"
|
||||||
"github.com/axllent/mailpit/internal/storage"
|
"github.com/axllent/mailpit/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -50,3 +51,6 @@ type HTMLCheckResponse = htmlcheck.Response
|
|||||||
|
|
||||||
// LinkCheckResponse summary
|
// LinkCheckResponse summary
|
||||||
type LinkCheckResponse = linkcheck.Response
|
type LinkCheckResponse = linkcheck.Response
|
||||||
|
|
||||||
|
// SpamAssassinResponse summary
|
||||||
|
type SpamAssassinResponse = spamassassin.Result
|
||||||
|
@ -146,6 +146,16 @@ type linkCheckParams struct {
|
|||||||
Follow string `json:"follow"`
|
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
|
// Binary data response inherits the attachment's content type
|
||||||
// swagger:response BinaryResponse
|
// swagger:response BinaryResponse
|
||||||
type binaryResponse string
|
type binaryResponse string
|
||||||
|
@ -26,6 +26,9 @@ type webUIConfiguration struct {
|
|||||||
|
|
||||||
// Whether the HTML check has been globally disabled
|
// Whether the HTML check has been globally disabled
|
||||||
DisableHTMLCheck bool
|
DisableHTMLCheck bool
|
||||||
|
|
||||||
|
// Whether SpamAssassin is enabled
|
||||||
|
SpamAssassin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebUIConfig returns configuration settings for the web UI.
|
// WebUIConfig returns configuration settings for the web UI.
|
||||||
@ -55,6 +58,7 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conf.DisableHTMLCheck = config.DisableHTMLCheck
|
conf.DisableHTMLCheck = config.DisableHTMLCheck
|
||||||
|
conf.SpamAssassin = config.EnableSpamAssassin != ""
|
||||||
|
|
||||||
bytes, _ := json.Marshal(conf)
|
bytes, _ := json.Marshal(conf)
|
||||||
|
|
||||||
|
@ -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}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
|
||||||
}
|
}
|
||||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/link-check", middleWareFunc(apiv1.LinkCheck)).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/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/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
|
||||||
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
||||||
|
@ -319,6 +319,12 @@ body.blur {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu.checks {
|
||||||
|
.dropdown-item {
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// bootstrap5-tags
|
// bootstrap5-tags
|
||||||
.tags-badge {
|
.tags-badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Attachments from './Attachments.vue'
|
import Attachments from './Attachments.vue'
|
||||||
import HTMLCheck from './HTMLCheck.vue'
|
|
||||||
import Headers from './Headers.vue'
|
import Headers from './Headers.vue'
|
||||||
|
import HTMLCheck from './HTMLCheck.vue'
|
||||||
import LinkCheck from './LinkCheck.vue'
|
import LinkCheck from './LinkCheck.vue'
|
||||||
|
import SpamAssassin from './SpamAssassin.vue'
|
||||||
import Prism from 'prismjs'
|
import Prism from 'prismjs'
|
||||||
import Tags from 'bootstrap5-tags'
|
import Tags from 'bootstrap5-tags'
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
import commonMixins from '../../mixins/CommonMixins'
|
||||||
@ -19,6 +20,7 @@ export default {
|
|||||||
Headers,
|
Headers,
|
||||||
HTMLCheck,
|
HTMLCheck,
|
||||||
LinkCheck,
|
LinkCheck,
|
||||||
|
SpamAssassin,
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [commonMixins],
|
mixins: [commonMixins],
|
||||||
@ -34,6 +36,8 @@ export default {
|
|||||||
htmlScore: false,
|
htmlScore: false,
|
||||||
htmlScoreColor: false,
|
htmlScoreColor: false,
|
||||||
linkCheckErrors: false,
|
linkCheckErrors: false,
|
||||||
|
spamScore: false,
|
||||||
|
spamScoreColor: false,
|
||||||
showMobileButtons: false,
|
showMobileButtons: false,
|
||||||
scaleHTMLPreview: 'display',
|
scaleHTMLPreview: 'display',
|
||||||
// keys names match bootstrap icon names
|
// keys names match bootstrap icon names
|
||||||
@ -386,13 +390,14 @@ export default {
|
|||||||
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
Checks
|
Checks
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu checks">
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
|
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
|
||||||
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
|
||||||
aria-selected="false" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
|
aria-selected="false" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
|
||||||
HTML Check
|
HTML Check
|
||||||
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
|
<span class="badge rounded-pill p-1 float-end" :class="htmlScoreColor"
|
||||||
|
v-if="htmlScore !== false">
|
||||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -402,12 +407,25 @@ export default {
|
|||||||
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
|
||||||
aria-selected="false">
|
aria-selected="false">
|
||||||
Link Check
|
Link Check
|
||||||
<i class="bi bi-check-all text-success" v-if="linkCheckErrors === 0"></i>
|
<span class="badge rounded-pill bg-success float-end" v-if="linkCheckErrors === 0">
|
||||||
<span class="badge rounded-pill bg-danger" v-else-if="linkCheckErrors > 0">
|
<small>0</small>
|
||||||
|
</span>
|
||||||
|
<span class="badge rounded-pill bg-danger float-end" v-else-if="linkCheckErrors > 0">
|
||||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="mailbox.uiConfig.SpamAssassin">
|
||||||
|
<button class="dropdown-item" id="nav-spam-check-tab" data-bs-toggle="tab"
|
||||||
|
data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||||
|
aria-selected="false">
|
||||||
|
Spam Analysis
|
||||||
|
<span class="badge rounded-pill float-end" :class="spamScoreColor"
|
||||||
|
v-if="spamScore !== false">
|
||||||
|
<small>{{ spamScore }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
|
||||||
@ -427,6 +445,14 @@ export default {
|
|||||||
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
<small>{{ formatNumber(linkCheckErrors) }}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
|
||||||
|
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html"
|
||||||
|
aria-selected="false" v-if="mailbox.uiConfig.SpamAssassin">
|
||||||
|
Spam Analysis
|
||||||
|
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
|
||||||
|
<small>{{ spamScore }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
||||||
<template v-for="vals, key in responsiveSizes">
|
<template v-for="vals, key in responsiveSizes">
|
||||||
@ -472,6 +498,11 @@ export default {
|
|||||||
<HTMLCheck v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
|
<HTMLCheck v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
|
||||||
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
|
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
|
||||||
|
tabindex="0">
|
||||||
|
<SpamAssassin v-if="mailbox.uiConfig.SpamAssassin" :message="message" @setSpamScore="(n) => spamScore = n"
|
||||||
|
@set-badge-style="(v) => spamScoreColor = v" />
|
||||||
|
</div>
|
||||||
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||||
tabindex="0">
|
tabindex="0">
|
||||||
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
|
<LinkCheck :message="message" @setLinkErrors="(n) => linkCheckErrors = n" />
|
||||||
|
299
server/ui-src/components/message/SpamAssassin.vue
Normal file
299
server/ui-src/components/message/SpamAssassin.vue
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
<script>
|
||||||
|
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import commonMixins from '../../mixins/CommonMixins'
|
||||||
|
import { Tooltip } from 'bootstrap'
|
||||||
|
import { mailbox } from '../../stores/mailbox'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
message: Object,
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
Donut,
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ["setSpamScore", "setBadgeStyle"],
|
||||||
|
|
||||||
|
mixins: [commonMixins],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
error: false,
|
||||||
|
check: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.doCheck()
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
message: {
|
||||||
|
handler() {
|
||||||
|
this.$emit('setSpamScore', false)
|
||||||
|
this.doCheck()
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
doCheck: function () {
|
||||||
|
this.check = false
|
||||||
|
|
||||||
|
let self = this
|
||||||
|
|
||||||
|
// ignore any error, do not show loader
|
||||||
|
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/sa-check'), null)
|
||||||
|
.then(function (result) {
|
||||||
|
self.check = result.data
|
||||||
|
self.error = false
|
||||||
|
self.setIcons()
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
// handle error
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
// The request was made and the server responded with a status code
|
||||||
|
// that falls out of the range of 2xx
|
||||||
|
if (error.response.data.Error) {
|
||||||
|
self.error = error.response.data.Error
|
||||||
|
} else {
|
||||||
|
self.error = error.response.data
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
// The request was made but no response was received
|
||||||
|
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||||
|
// http.ClientRequest in node.js
|
||||||
|
self.error = 'Error sending data to the server. Please try again.'
|
||||||
|
} else {
|
||||||
|
// Something happened in setting up the request that triggered an Error
|
||||||
|
self.error = error.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
badgeStyle: function (ignorePadding = false) {
|
||||||
|
let badgeStyle = 'bg-success'
|
||||||
|
if (this.check.Error) {
|
||||||
|
badgeStyle = 'bg-warning text-primary'
|
||||||
|
}
|
||||||
|
else if (this.check.IsSpam) {
|
||||||
|
badgeStyle = 'bg-danger'
|
||||||
|
} else if (this.check.Score >= 4) {
|
||||||
|
badgeStyle = 'bg-warning text-primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ignorePadding && String(this.check.Score).includes('.')) {
|
||||||
|
badgeStyle += " p-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
return badgeStyle
|
||||||
|
},
|
||||||
|
|
||||||
|
setIcons: function () {
|
||||||
|
let score = this.check.Score
|
||||||
|
if (this.check.Error && this.check.Error != '') {
|
||||||
|
score = '!'
|
||||||
|
}
|
||||||
|
let badgeStyle = this.badgeStyle()
|
||||||
|
this.$emit('setBadgeStyle', badgeStyle)
|
||||||
|
this.$emit('setSpamScore', score)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
graphSections: function () {
|
||||||
|
let score = this.check.Score
|
||||||
|
let p = Math.round(score / 5 * 100)
|
||||||
|
if (p > 100) {
|
||||||
|
p = 100
|
||||||
|
} else if (p < 0) {
|
||||||
|
p = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = '#ffc107'
|
||||||
|
if (this.check.IsSpam) {
|
||||||
|
c = '#dc3545'
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: score + ' / 5',
|
||||||
|
value: p,
|
||||||
|
color: c
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="row mb-3 w-100 align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<h4 class="mb-0">Spam Analysis</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#AboutSpamAnalysis">
|
||||||
|
<i class="bi bi-info-circle-fill"></i>
|
||||||
|
Help
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="error || check.Error != ''">
|
||||||
|
<p>Your message could not be checked</p>
|
||||||
|
<div class="alert alert-warning" v-if="error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning" v-else>
|
||||||
|
There was an error contacting the configured SpamAssassin server: {{ check.Error }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="check">
|
||||||
|
<div class="row w-100 mt-5">
|
||||||
|
<div class="col-xl-5 mb-2">
|
||||||
|
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="230" unit="px" :thickness="20"
|
||||||
|
:total="100" :start-angle="270" :auto-adjust-text-size="true" foreground="#198754">
|
||||||
|
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||||
|
{{ check.Score }} / 5
|
||||||
|
</h2>
|
||||||
|
<div class="text-body mt-2">
|
||||||
|
<span v-if="check.IsSpam" class="text-white badge rounded-pill bg-danger p-2">Spam</span>
|
||||||
|
<span v-else class="badge rounded-pill p-2" :class="badgeStyle()">Not spam</span>
|
||||||
|
</div>
|
||||||
|
</Donut>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-7">
|
||||||
|
<div class="row w-100 py-2 border-bottom">
|
||||||
|
<div class="col-2 col-lg-1">
|
||||||
|
<strong>Score</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-10 col-lg-5">
|
||||||
|
<strong>Rule <span class="d-none d-lg-inline">name</span></strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-none d-lg-block">
|
||||||
|
<strong>Description</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row w-100 py-2 border-bottom small" v-for="r in check.Rules">
|
||||||
|
<div class="col-2 col-lg-1">
|
||||||
|
{{ r.Score }}
|
||||||
|
</div>
|
||||||
|
<div class="col-10 col-lg-5">
|
||||||
|
{{ r.Name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-auto col-lg-6 mt-2 mt-lg-0 offset-2 offset-lg-0">
|
||||||
|
{{ r.Description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="modal fade" id="AboutSpamAnalysis" tabindex="-1" aria-labelledby="AboutSpamAnalysisLabel"
|
||||||
|
aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="AboutSpamAnalysisLabel">About Spam Analysis</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>
|
||||||
|
Spam Analysis is currently in beta. Constructive feedback is welcome via
|
||||||
|
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||||
|
</p>
|
||||||
|
<div class="accordion" id="SpamAnalysisAboutAccordion">
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||||
|
What is Spam Analysis?
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<p>
|
||||||
|
Mailpit integrates with SpamAssassin to provide you with some insight into the
|
||||||
|
"spamminess" of your messages. It sends your complete message (including any
|
||||||
|
attachments) to a running SpamAssassin server and then displays the results returned
|
||||||
|
by SpamAssassin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||||
|
How does the point system work?
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<p>
|
||||||
|
The default spam threshold is <code>5</code>, meaning any score lower than 5 is
|
||||||
|
considered ham (not spam), and any score of 5 or above is spam.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
SpamAssassin will also return the tests which are triggered by the message. These
|
||||||
|
tests can differ depending on the configuration of your SpamAssassin server. The
|
||||||
|
total of this score makes up the the "spamminess" of the message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||||
|
But I don't agree with the results...
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<p>
|
||||||
|
Mailpit does not manipulate the results nor determine the "spamminess" of
|
||||||
|
your message. The result is what SpamAssassin returns, and it entirely
|
||||||
|
dependent on how SpamAssassin is set up and optionally trained.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This tool is simply provided as an aid to assist you. If you are running your own
|
||||||
|
instance of SpamAssassin, then you look into your SpamAssassin configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||||
|
Where can I find more information about the triggered rules?
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<p>
|
||||||
|
Unfortunately the current <a href="https://spamassassin.apache.org/"
|
||||||
|
target="_blank">SpamAssassin website</a> no longer contains any relative
|
||||||
|
documentation
|
||||||
|
about these, most likely because the rules come from different locations and change
|
||||||
|
often. You will need to search the internet for these yourself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@ -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": {
|
"/api/v1/messages": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns messages from the mailbox ordered from newest to oldest.",
|
"description": "Returns messages from the mailbox ordered from newest to oldest.",
|
||||||
@ -1299,6 +1336,54 @@
|
|||||||
},
|
},
|
||||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
"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": {
|
"WebUIConfiguration": {
|
||||||
"description": "Response includes global web UI settings",
|
"description": "Response includes global web UI settings",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -1328,6 +1413,10 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SpamAssassin": {
|
||||||
|
"description": "Whether SpamAssassin is enabled",
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-go-name": "webUIConfiguration",
|
"x-go-name": "webUIConfiguration",
|
||||||
|
Reference in New Issue
Block a user