Compare commits

...

33 Commits

Author SHA1 Message Date
Ralph Slooten
f87ec396c9 Merge branch 'release/v1.18.2' 2024-05-15 16:12:02 +12:00
Ralph Slooten
3e37293c99 Release v1.18.2 2024-05-15 16:12:02 +12:00
Ralph Slooten
7147032c6b Chore: Update node dependencies 2024-05-15 16:10:48 +12:00
Ralph Slooten
3c36951113 Fix: Replace invalid Windows username characters in sendmail (#294) 2024-05-15 16:09:36 +12:00
Ralph Slooten
86e8a126ca Merge tag 'v1.18.1' into develop
Release v1.18.1
2024-05-09 17:03:21 +12:00
Ralph Slooten
7f586e15cf Merge branch 'release/v1.18.1' 2024-05-09 17:03:16 +12:00
Ralph Slooten
2a5559f5f0 Release v1.18.1 2024-05-09 17:03:16 +12:00
Ralph Slooten
ead3fad1dd Merge branch 'feature/smtp-message-id' into develop 2024-05-09 16:58:00 +12:00
Ralph Slooten
abd546133e Chore: Update node dependencies 2024-05-09 16:57:31 +12:00
Ralph Slooten
fae0384dfe Feature: Return queued Message ID in SMTP response (#293) 2024-05-09 16:56:39 +12:00
Ralph Slooten
aa1a5a0954 Chore: Update Go dependencies 2024-05-09 16:56:29 +12:00
Ralph Slooten
c81ea54c87 Remove redundant references to beta testing 2024-05-05 15:50:56 +12:00
Ralph Slooten
ebf7bb6348 Chore: Simplify JSON HTTP responses 2024-05-05 12:25:26 +12:00
Ralph Slooten
ba0e40fc7f Merge tag 'v1.18.0' into develop
Release v1.18.0
2024-05-04 11:18:40 +12:00
Ralph Slooten
9f0d393cee Merge branch 'release/v1.18.0' 2024-05-04 11:18:37 +12:00
Ralph Slooten
154cc5d392 Release v1.18.0 2024-05-04 11:18:37 +12:00
Ralph Slooten
4c31b49f18 Chore: Update node dependencies 2024-05-04 11:10:06 +12:00
Ralph Slooten
65adb6bc26 Chore: Update Go dependencies 2024-05-04 11:07:58 +12:00
Ralph Slooten
ea56cae43a Chore: Update go-release-action 2024-05-04 11:06:56 +12:00
Ralph Slooten
f424856685 Chore: JSON key case-consistency for posted API data (backwards-compatible) 2024-05-04 11:05:07 +12:00
Ralph Slooten
22d28a7b18 Chore: Remove function duplication - use common tools.InArray() 2024-05-04 10:20:46 +12:00
Ralph Slooten
a15f032b32 Feature: API endpoint for sending (#278) 2024-05-04 10:15:30 +12:00
Ralph Slooten
fce486553b Update screenshot 2024-04-26 16:11:54 +12:00
Ralph Slooten
96d0febd0e Merge branch 'feature/tag-filters' into develop 2024-04-26 14:52:21 +12:00
Ralph Slooten
dddc52a668 Feature: Set tagging filters via a config file 2024-04-26 14:52:10 +12:00
Ralph Slooten
65fb188586 Do not export autoTag struct 2024-04-25 23:18:46 +12:00
Ralph Slooten
15a5910695 Feature: Search filter support for auto-tagging 2024-04-25 23:04:35 +12:00
Ralph Slooten
6585d450c0 Feature: New search filter prefix addressed: includes From, To, Cc, Bcc & Reply-To 2024-04-25 22:13:57 +12:00
Ralph Slooten
1af32ebf8f Chore: Improve tag sorting in web UI, ignore casing 2024-04-25 14:45:36 +12:00
Ralph Slooten
5f2e548ba6 Merge branch 'feature/dayjs' into develop 2024-04-24 19:21:08 +12:00
Ralph Slooten
3b8eb44490 Chore: Replace moment JS library with dayjs 2024-04-24 19:19:37 +12:00
Ralph Slooten
8b067765e9 Chore: Auto-update relative received message times 2024-04-24 19:18:22 +12:00
Ralph Slooten
26ce538c45 Merge tag 'v1.17.1' into develop
Release v1.17.1
2024-04-24 16:53:19 +12:00
37 changed files with 1635 additions and 728 deletions

View File

@@ -33,7 +33,7 @@ jobs:
- run: npm run package
# build the binaries
- uses: wangyoucao577/go-release-action@v1.49
- uses: wangyoucao577/go-release-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}

View File

@@ -2,6 +2,45 @@
Notable changes to Mailpit will be documented in this file.
## [v1.18.2]
### Chore
- Update node dependencies
### Fix
- Replace invalid Windows username characters in sendmail ([#294](https://github.com/axllent/mailpit/issues/294))
## [v1.18.1]
### Chore
- Update node dependencies
- Update Go dependencies
- Simplify JSON HTTP responses
### Feature
- Return queued Message ID in SMTP response ([#293](https://github.com/axllent/mailpit/issues/293))
## [v1.18.0]
### Chore
- Update node dependencies
- Update Go dependencies
- Update go-release-action
- JSON key case-consistency for posted API data (backwards-compatible)
- Remove function duplication - use common tools.InArray()
- Improve tag sorting in web UI, ignore casing
- Replace moment JS library with dayjs
- Auto-update relative received message times
### Feature
- API endpoint for sending ([#278](https://github.com/axllent/mailpit/issues/278))
- Set tagging filters via a config file
- Search filter support for auto-tagging
- New search filter prefix `addressed:` includes From, To, Cc, Bcc & Reply-To
## [v1.17.1]
### Chore

View File

@@ -127,8 +127,9 @@ func init() {
rootCmd.Flags().StringVar(&config.POP3TLSKey, "pop3-tls-key", config.POP3TLSKey, "Optional TLS key for POP3 server - requires pop3-tls-cert")
// Tagging
rootCmd.Flags().StringVarP(&config.SMTPCLITags, "tag", "t", config.SMTPCLITags, "Tag new messages matching filters")
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "Convert new tags automatically to TitleCase")
rootCmd.Flags().StringVarP(&config.CLITagsArg, "tag", "t", config.CLITagsArg, "Tag new messages matching filters")
rootCmd.Flags().StringVar(&config.TagsConfig, "tags-config", config.TagsConfig, "Load tags filters from yaml configuration file")
rootCmd.Flags().BoolVar(&tools.TagsTitleCase, "tags-title-case", tools.TagsTitleCase, "TitleCase new tags generated from plus-addresses and X-Tags")
// Webhook
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
@@ -283,12 +284,9 @@ func initConfigFromEnv() {
config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY")
// Tagging
if len(os.Getenv("MP_TAG")) > 0 {
config.SMTPCLITags = os.Getenv("MP_TAG")
}
if getEnabledFromEnv("MP_TAGS_TITLE_CASE") {
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
}
config.CLITagsArg = os.Getenv("MP_TAG")
config.TagsConfig = os.Getenv("MP_TAGS_CONFIG")
tools.TagsTitleCase = getEnabledFromEnv("MP_TAGS_TITLE_CASE")
// Webhook
if len(os.Getenv("MP_WEBHOOK_URL")) > 0 {

View File

@@ -15,7 +15,6 @@ 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"
)
@@ -86,14 +85,17 @@ var (
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
BlockRemoteCSSAndFonts = false
// SMTPCLITags is used to map the CLI args
SMTPCLITags string
// CLITagsArg is used to map the CLI args
CLITagsArg string
// ValidTagRegexp represents a valid tag
ValidTagRegexp = regexp.MustCompile(`^([a-zA-Z0-9\-\ \_\.]){1,}$`)
// SMTPTags are expressions to apply tags to new mail
SMTPTags []AutoTag
// TagsConfig is a yaml file to pre-load tags
TagsConfig string
// TagFilters are used to apply tags to new mail
TagFilters []autoTag
// SMTPRelayConfigFile to parse a yaml file and store config of relay SMTP server
SMTPRelayConfigFile string
@@ -162,9 +164,9 @@ var (
)
// AutoTag struct for auto-tagging
type AutoTag struct {
Tag string
type autoTag struct {
Match string
Tags []string
}
// SMTPRelayConfigStruct struct for parsing yaml & storing variables
@@ -381,27 +383,13 @@ func VerifyConfig() error {
}
}
SMTPTags = []AutoTag{}
if SMTPCLITags != "" {
args := tools.ArgsParser(SMTPCLITags)
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
tag := tools.CleanTag(t[0])
if !ValidTagRegexp.MatchString(tag) || len(tag) == 0 {
return fmt.Errorf("[tag] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tag)
}
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
if len(match) == 0 {
return fmt.Errorf("[tag] invalid tag match (%s) - no search detected", tag)
}
SMTPTags = append(SMTPTags, AutoTag{Tag: tag, Match: match})
} else {
return fmt.Errorf("[tag] error parsing tags (%s)", a)
}
}
// load tag filters
TagFilters = []autoTag{}
if err := loadTagsFromArgs(CLITagsArg); err != nil {
return err
}
if err := loadTagsFromConfig(TagsConfig); err != nil {
return err
}
if SMTPAllowedRecipients != "" {

81
config/tags.go Normal file
View File

@@ -0,0 +1,81 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)
type yamlTags struct {
Filters []yamlTag `yaml:"filters"`
}
type yamlTag struct {
Match string `yaml:"match"`
Tags string `yaml:"tags"`
}
// Load tags from a configuration from a file, if set
func loadTagsFromConfig(c string) error {
if c == "" {
return nil // not set, ignore
}
c = filepath.Clean(c)
if !isFile(c) {
return fmt.Errorf("[tags] configuration file not found or unreadable: %s", c)
}
data, err := os.ReadFile(c)
if err != nil {
return fmt.Errorf("[tags] %s", err.Error())
}
conf := yamlTags{}
if err := yaml.Unmarshal(data, &conf); err != nil {
return err
}
if conf.Filters == nil {
return fmt.Errorf("[tags] missing tag: array in %s", c)
}
for _, t := range conf.Filters {
tags := strings.Split(t.Tags, ",")
TagFilters = append(TagFilters, autoTag{Match: t.Match, Tags: tags})
}
logger.Log().Debugf("[tags] loaded %s from config %s", tools.Plural(len(conf.Filters), "tag filter", "tag filters"), c)
return nil
}
func loadTagsFromArgs(c string) error {
if c == "" {
return nil // not set, ignore
}
args := tools.ArgsParser(c)
for _, a := range args {
t := strings.Split(a, "=")
if len(t) > 1 {
match := strings.TrimSpace(strings.ToLower(strings.Join(t[1:], "=")))
tags := strings.Split(t[0], ",")
TagFilters = append(TagFilters, autoTag{Match: match, Tags: tags})
} else {
return fmt.Errorf("[tag] error parsing tags (%s)", a)
}
}
logger.Log().Debugf("[tags] loaded %s from CLI args", tools.Plural(len(args), "tag filter", "tag filters"))
return nil
}

20
go.mod
View File

@@ -5,7 +5,7 @@ go 1.21.0
toolchain go1.22.1
require (
github.com/PuerkitoBio/goquery v1.9.1
github.com/PuerkitoBio/goquery v1.9.2
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/semver v0.0.1
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2
@@ -16,19 +16,19 @@ require (
github.com/kovidgoyal/imaging v1.6.3
github.com/leporo/sqlf v1.4.0
github.com/lithammer/shortuuid/v4 v4.0.0
github.com/mhale/smtpd v0.8.2
github.com/mhale/smtpd v0.8.3
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.2
github.com/vanng822/go-premailer v1.20.2
golang.org/x/net v0.24.0
golang.org/x/text v0.14.0
github.com/vanng822/go-premailer v1.21.0
golang.org/x/net v0.25.0
golang.org/x/text v0.15.0
golang.org/x/time v0.5.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.8
modernc.org/sqlite v1.29.9
)
require (
@@ -54,12 +54,12 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/image v0.16.0 // indirect
golang.org/x/sys v0.20.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.50.2 // indirect
modernc.org/libc v1.50.5 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect

66
go.sum
View File

@@ -1,9 +1,8 @@
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
@@ -31,7 +30,6 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwg
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -67,11 +65,10 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mhale/smtpd v0.8.2 h1:rHKOMHeFoDvcq8Na9ErCbNcjlWTSyGtznOmJpWsOzuc=
github.com/mhale/smtpd v0.8.2/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
github.com/mhale/smtpd v0.8.3/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@@ -121,38 +118,36 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
github.com/vanng822/go-premailer v1.20.2 h1:vKs4VdtfXDqL7IXC2pkiBObc1bXM9bYH3Wa+wYw2DnI=
github.com/vanng822/go-premailer v1.20.2/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
github.com/vanng822/go-premailer v1.21.0 h1:qIwX4urphNPO3xa60MGqowmyjzzMtFacJPKNrt1UWFU=
github.com/vanng822/go-premailer v1.21.0/go.mod h1:6Y3H2NzNmK3sFBNgR1ENdfV9hzG8hMzrA1nL/XBbbP4=
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -161,19 +156,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -193,16 +195,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.0 h1:D/gLKtcztomvWbsbvBKo3leKQv+86f+DdqEZBBXhnag=
modernc.org/cc/v4 v4.21.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.17.0 h1:cX97L5Bv/7PEmyk1oEAD890fQu5/yUQRYeYBsCSnzww=
modernc.org/ccgo/v4 v4.17.0/go.mod h1:keES1eiOIBJhbA5qKrV7ADG3w8DsX8G7jfHAT76riOg=
modernc.org/ccgo/v4 v4.17.3 h1:t2CQci84jnxKw3GGnHvjGKjiNZeZqyQx/023spkk4hU=
modernc.org/ccgo/v4 v4.17.3/go.mod h1:1FCbAtWYJoKuc+AviS+dH+vGNtYmFJqBeRWjmnDWsIg=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.50.2 h1:I0+3wlRvXmAEjAJvD7BhP1kmKHwkzV0rOcqFcD85u+0=
modernc.org/libc v1.50.2/go.mod h1:Fd8TZdfRorOd1vB0QCtYSHYAuzobS4xS3mhMGUkeVcA=
modernc.org/libc v1.50.5 h1:ZzeUd0dIc/sUtoPTCYIrgypkuzoGzNu6kbEWj2VuEmk=
modernc.org/libc v1.50.5/go.mod h1:rhzrUx5oePTSTIzBgM0mTftwWHK8tiT9aNFUt1mldl0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
@@ -211,8 +213,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8=
modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk=
modernc.org/sqlite v1.29.9 h1:9RhNMklxJs+1596GNuAX+O/6040bvOwacTxuFcRuQow=
modernc.org/sqlite v1.29.9/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/axllent/mailpit/internal/tools"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
@@ -136,12 +137,12 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
var y, n, p float32
for family, stats := range found.Stats {
if len(LimitFamilies) != 0 && !inArray(family, LimitFamilies) {
if len(LimitFamilies) != 0 && !tools.InArray(family, LimitFamilies) {
continue
}
for platform, clients := range stats.(map[string]interface{}) {
if len(LimitPlatforms) != 0 && !inArray(platform, LimitPlatforms) {
if len(LimitPlatforms) != 0 && !tools.InArray(platform, LimitPlatforms) {
continue
}
for version, support := range clients.(map[string]interface{}) {
@@ -182,18 +183,6 @@ func (c CanIEmail) getTest(k string) (Warning, error) {
return warning, nil
}
func inArray(n string, h []string) bool {
n = strings.ToLower(n)
for _, v := range h {
if strings.ToLower(v) == n {
return true
}
}
return false
}
// Convert markdown to HTML, stripping <p> & </p>
func mdToHTML(str string) string {
md := []byte(str)

View File

@@ -1,6 +1,10 @@
package htmlcheck
import "sort"
import (
"sort"
"github.com/axllent/mailpit/internal/tools"
)
// Platforms returns all platforms with their respective email clients
func Platforms() (map[string][]string, error) {
@@ -19,7 +23,7 @@ func Platforms() (map[string][]string, error) {
if !found {
data[platform] = []string{}
}
if !inArray(niceFamily, c) {
if !tools.InArray(niceFamily, c) {
c = append(c, niceFamily)
data[platform] = c
}

View File

@@ -108,6 +108,8 @@ func InitDB() error {
return err
}
LoadTagFilters()
dbFile = p
dbLastAction = time.Now()

View File

@@ -71,13 +71,6 @@ func Store(body *[]byte) (string, error) {
return "", err
}
// extract tags from body matches based on --tag, plus addresses & X-Tags header
tagStr := findTagsInRawMessage(body) + "," +
obj.tagsFromPlusAddresses() + "," +
strings.TrimSpace(env.Root.Header.Get("X-Tags"))
tagData := uniqueTagsFromString(tagStr)
// begin a transaction to ensure both the message
// and data are stored successfully
ctx := context.Background()
@@ -119,9 +112,23 @@ func Store(body *[]byte) (string, error) {
return "", err
}
if len(tagData) > 0 {
// set tags after tx.Commit()
if err := SetMessageTags(id, tagData); err != nil {
// extract tags from body matches
rawTags := findTagsInRawMessage(body)
// extract plus addresses tags from enmime.Envelope
plusTags := obj.tagsFromPlusAddresses()
// extract tags from X-Tags header
xTags := tools.SetTagCasing(strings.Split(strings.TrimSpace(env.Root.Header.Get("X-Tags")), ","))
// extract tags from search matches
searchTags := tagFilterMatches(id)
// combine all tags into one slice
tags := append(rawTags, plusTags...)
tags = append(tags, xTags...)
// sort and extract only unique tags
tags = sortedUniqueTags(append(tags, searchTags...))
if len(tags) > 0 {
if err := SetMessageTags(id, tags); err != nil {
return "", err
}
}
@@ -137,7 +144,7 @@ func Store(body *[]byte) (string, error) {
c.Attachments = attachments
c.Subject = subject
c.Size = size
c.Tags = tagData
c.Tags = tags
c.Snippet = snippet
websockets.Broadcast("new", c)

View File

@@ -294,6 +294,16 @@ func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
q.Where("ReplyToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(lw, "addressed:") {
w = cleanString(w[10:])
arg := "%" + escPercentChar(w) + "%"
if w != "" {
if exclude {
q.Where("(ToJSON NOT LIKE ? AND FromJSON NOT LIKE ? AND CcJSON NOT LIKE ? AND BccJSON NOT LIKE ? AND ReplyToJSON NOT LIKE ?)", arg, arg, arg, arg, arg)
} else {
q.Where("(ToJSON LIKE ? OR FromJSON LIKE ? OR CcJSON LIKE ? OR BccJSON LIKE ? OR ReplyToJSON LIKE ?)", arg, arg, arg, arg, arg)
}
}
} else if strings.HasPrefix(lw, "subject:") {
w = w[8:]
if w != "" {

View File

@@ -0,0 +1,84 @@
package storage
import (
"context"
"database/sql"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf"
)
// TagFilter struct
type TagFilter struct {
Match string
SQL *sqlf.Stmt
Tags []string
}
var tagFilters = []TagFilter{}
// LoadTagFilters loads tag filters from the config and pre-generates the SQL query
func LoadTagFilters() {
tagFilters = []TagFilter{}
for _, t := range config.TagFilters {
match := strings.TrimSpace(t.Match)
if match == "" {
logger.Log().Warnf("[tags] ignoring tag item with missing 'match'")
continue
}
if t.Tags == nil || len(t.Tags) == 0 {
logger.Log().Warnf("[tags] ignoring tag items with missing 'tags' array")
continue
}
validTags := []string{}
for _, tag := range t.Tags {
tagName := tools.CleanTag(tag)
if !config.ValidTagRegexp.MatchString(tagName) || len(tagName) == 0 {
logger.Log().Warnf("[tags] invalid tag (%s) - can only contain spaces, letters, numbers, - & _", tagName)
continue
}
validTags = append(validTags, tagName)
}
if len(validTags) == 0 {
continue
}
tagFilters = append(tagFilters, TagFilter{Match: match, Tags: validTags, SQL: searchQueryBuilder(match, "")})
}
}
// TagFilterMatches returns a slice of matching tags from a message
func tagFilterMatches(id string) []string {
tags := []string{}
if len(tagFilters) == 0 {
return tags
}
for _, f := range tagFilters {
var matchID string
q := f.SQL.Clone().Where("ID = ?", id)
if err := q.QueryAndClose(context.Background(), db, func(row *sql.Rows) {
var ignore sql.NullString
if err := row.Scan(&ignore, &matchID, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return tags
}
if matchID == id {
tags = append(tags, f.Tags...)
}
}
return tags
}

View File

@@ -1,6 +1,7 @@
package storage
import (
"bytes"
"context"
"database/sql"
"regexp"
@@ -19,12 +20,12 @@ var (
addTagMutex sync.RWMutex
)
// SetMessageTags will set the tags for a given database ID
// SetMessageTags will set the tags for a given database ID, removing any not in the array
func SetMessageTags(id string, tags []string) error {
applyTags := []string{}
for _, t := range tags {
t = tools.CleanTag(t)
if t != "" && config.ValidTagRegexp.MatchString(t) && !inArray(t, applyTags) {
if t != "" && config.ValidTagRegexp.MatchString(t) && !tools.InArray(t, applyTags) {
applyTags = append(applyTags, t)
}
}
@@ -33,8 +34,7 @@ func SetMessageTags(id string, tags []string) error {
origTagCount := len(currentTags)
for _, t := range applyTags {
t = tools.CleanTag(t)
if t == "" || !config.ValidTagRegexp.MatchString(t) || inArray(t, currentTags) {
if t == "" || !config.ValidTagRegexp.MatchString(t) || tools.InArray(t, currentTags) {
continue
}
@@ -47,7 +47,7 @@ func SetMessageTags(id string, tags []string) error {
currentTags = getMessageTags(id)
for _, t := range currentTags {
if !inArray(t, applyTags) {
if !tools.InArray(t, applyTags) {
if err := DeleteMessageTag(id, t); err != nil {
return err
}
@@ -74,14 +74,15 @@ func AddMessageTag(id, name string) error {
addTagMutex.Unlock()
// check message does not already have this tag
var count int
if _, err := sqlf.From(tenant("message_tags")).
if err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&count).
Where("ID = ?", id).
Where("TagID = ?", tagID).
ExecAndClose(context.TODO(), db); err != nil {
QueryRowAndClose(context.Background(), db); err != nil {
return err
}
if count != 0 {
if count > 0 {
// already exists
return nil
}
@@ -213,26 +214,28 @@ func pruneUnusedTags() error {
return nil
}
// Find tags set via --tags in raw message.
// Find tags set via --tags in raw message, useful for matching all headers etc.
// This function is largely superseded by the database searching, however this
// includes literally everything and is kept for backwards compatibility.
// Returns a comma-separated string.
func findTagsInRawMessage(message *[]byte) string {
tagStr := ""
if len(config.SMTPTags) == 0 {
return tagStr
func findTagsInRawMessage(message *[]byte) []string {
tags := []string{}
if len(tagFilters) == 0 {
return tags
}
str := strings.ToLower(string(*message))
for _, t := range config.SMTPTags {
if strings.Contains(str, t.Match) {
tagStr += "," + t.Tag
str := bytes.ToLower(*message)
for _, t := range tagFilters {
if bytes.Contains(str, []byte(t.Match)) {
tags = append(tags, t.Tags...)
}
}
return tagStr
return tags
}
// Returns tags found in email plus addresses (eg: test+tagname@example.com)
func (d DBMailSummary) tagsFromPlusAddresses() string {
func (d DBMailSummary) tagsFromPlusAddresses() []string {
tags := []string{}
for _, c := range d.To {
matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
@@ -257,7 +260,7 @@ func (d DBMailSummary) tagsFromPlusAddresses() string {
tags = append(tags, strings.Split(matches[0][2], "+")...)
}
return strings.Join(tags, ",")
return tools.SetTagCasing(tags)
}
// Get message tags from the database for a given database ID
@@ -282,24 +285,27 @@ func getMessageTags(id string) []string {
return tags
}
// UniqueTagsFromString will split a string with commas, and extract a unique slice of formatted tags
func uniqueTagsFromString(s string) []string {
// SortedUniqueTags will return a unique slice of normalised tags
func sortedUniqueTags(s []string) []string {
tags := []string{}
added := make(map[string]bool)
if s == "" {
if len(s) == 0 {
return tags
}
parts := strings.Split(s, ",")
for _, p := range parts {
for _, p := range s {
w := tools.CleanTag(p)
if w == "" {
continue
}
lc := strings.ToLower(w)
if _, exists := added[lc]; exists {
continue
}
if config.ValidTagRegexp.MatchString(w) {
if !inArray(w, tags) {
tags = append(tags, w)
}
added[lc] = true
tags = append(tags, w)
} else {
logger.Log().Debugf("[tags] ignoring invalid tag: %s", w)
}

View File

@@ -87,18 +87,6 @@ func isFile(path string) bool {
return true
}
// Tests if a string is within an array. It is not case sensitive.
func inArray(k string, arr []string) bool {
k = strings.ToLower(k)
for _, v := range arr {
if strings.ToLower(v) == k {
return true
}
}
return false
}
// Convert `%` to `%%` for SQL searches
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")

View File

@@ -19,18 +19,29 @@ var (
TagsTitleCase bool
)
// CleanTag returns a clean tag, removing whitespace and invalid characters
// CleanTag returns a clean tag, trimming whitespace and replacing invalid characters
func CleanTag(s string) string {
s = strings.TrimSpace(
return strings.TrimSpace(
multiSpaceRe.ReplaceAllString(
tagsInvalidChars.ReplaceAllString(s, " "),
" ",
),
)
}
if TagsTitleCase {
return cases.Title(language.Und, cases.NoLower).String(s)
// SetTagCasing returns the slice of tags, title-casing if set
func SetTagCasing(s []string) []string {
if !TagsTitleCase {
return s
}
return s
titleTags := []string{}
c := cases.Title(language.Und, cases.NoLower)
for _, t := range s {
titleTags = append(titleTags, c.String(t))
}
return titleTags
}

26
internal/tools/utils.go Normal file
View File

@@ -0,0 +1,26 @@
package tools
import (
"fmt"
"strings"
)
// Plural returns a singular or plural of a word together with the total
func Plural(total int, singular, plural string) string {
if total == 1 {
return fmt.Sprintf("%d %s", total, singular)
}
return fmt.Sprintf("%d %s", total, plural)
}
// InArray tests if a string is within an array. It is not case sensitive.
func InArray(k string, arr []string) bool {
k = strings.ToLower(k)
for _, v := range arr {
if strings.ToLower(v) == k {
return true
}
}
return false
}

816
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,8 @@
"bootstrap-icons": "^1.9.1",
"bootstrap5-tags": "^1.6.1",
"color-hash": "^2.0.2",
"dayjs": "^1.11.10",
"modern-screenshot": "^4.4.30",
"moment": "^2.29.4",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
"timezones-list": "^3.0.3",

View File

@@ -21,6 +21,7 @@ import (
"net/smtp"
"os"
"os/user"
"regexp"
"strings"
"github.com/axllent/mailpit/config"
@@ -42,15 +43,19 @@ var (
)
func init() {
// ensure only valid characters are used, ie: windows
re := regexp.MustCompile(`[^a-zA-Z\-\.\_]`)
host, err := os.Hostname()
if err != nil {
host = "localhost"
} else {
host = re.ReplaceAllString(host, "-")
}
username := "nobody"
user, err := user.Current()
if err == nil && user != nil && len(user.Username) > 0 {
username = user.Username
username = re.ReplaceAllString(user.Username, "-")
}
if FromAddr == "" {
@@ -62,7 +67,7 @@ func init() {
func Run() {
var recipients []string
// defaults from envars if provided
// defaults from env vars if provided
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
SMTPAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
}

View File

@@ -73,9 +73,10 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
res.Tags = stats.Tags
res.MessagesCount = stats.Total
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// Search returns the latest messages as JSON
@@ -144,9 +145,10 @@ func Search(w http.ResponseWriter, r *http.Request) {
res.Unread = stats.Unread
res.Tags = stats.Tags
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(res); err != nil {
httpError(w, err.Error())
}
}
// DeleteSearch will delete all messages matching a search
@@ -238,9 +240,10 @@ func GetMessage(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(msg); err != nil {
httpError(w, err.Error())
}
}
// DownloadAttachment (method: GET) returns the attachment data
@@ -347,14 +350,10 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
return
}
bytes, err := json.Marshal(m.Header)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(m.Header); err != nil {
httpError(w, err.Error())
}
}
// DownloadRaw (method: GET) returns the full email source as plain text
@@ -541,16 +540,10 @@ func GetAllTags(w http.ResponseWriter, _ *http.Request) {
// 200: ArrayResponse
// default: ErrorResponse
tags := storage.GetAllTags()
data, err := json.Marshal(tags)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(data)
if err := json.NewEncoder(w).Encode(storage.GetAllTags()); err != nil {
httpError(w, err.Error())
}
}
// SetMessageTags (method: PUT) will set the tags for all provided IDs
@@ -666,18 +659,18 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
return
}
froms, err := m.Header.AddressList("From")
fromAddresses, err := m.Header.AddressList("From")
if err != nil {
httpError(w, err.Error())
return
}
if len(froms) == 0 {
if len(fromAddresses) == 0 {
httpError(w, "No From header found")
return
}
from := froms[0].Address
from := fromAddresses[0].Address
// if sender is used, then change from to the sender
if senders, err := m.Header.AddressList("Sender"); err == nil {
@@ -734,7 +727,7 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck
//
// # HTML check (beta)
// # HTML check
//
// Returns the summary of the message HTML checker.
//
@@ -777,16 +770,17 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(checks)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(checks); err != nil {
httpError(w, err.Error())
}
}
// LinkCheck returns a summary of links in the email
func LinkCheck(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID}/link-check Other LinkCheck
//
// # Link check (beta)
// # Link check
//
// Returns the summary of the message Link checker.
//
@@ -827,21 +821,19 @@ func LinkCheck(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(summary)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// 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)
// # SpamAssassin check
//
// 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.
// Returns the SpamAssassin summary (if enabled) of the message.
//
// Produces:
// - application/json
@@ -877,9 +869,10 @@ func SpamAssassinCheck(w http.ResponseWriter, r *http.Request) {
return
}
bytes, _ := json.Marshal(summary)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(summary); err != nil {
httpError(w, err.Error())
}
}
// FourOFour returns a basic 404 message
@@ -900,6 +893,21 @@ func httpError(w http.ResponseWriter, msg string) {
fmt.Fprint(w, msg)
}
// httpJSONError returns a basic error message (400 response) in JSON format
func httpJSONError(w http.ResponseWriter, msg string) {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
w.WriteHeader(http.StatusBadRequest)
e := JSONErrorMessage{
Error: msg,
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(e); err != nil {
httpError(w, err.Error())
}
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0

View File

@@ -24,10 +24,8 @@ func AppInfo(w http.ResponseWriter, _ *http.Request) {
// 200: InfoResponse
// default: ErrorResponse
info := stats.Load()
bytes, _ := json.Marshal(info)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(stats.Load()); err != nil {
httpError(w, err.Error())
}
}

275
server/apiv1/send.go Normal file
View File

@@ -0,0 +1,275 @@
package apiv1
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/mail"
"strings"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/smtpd"
"github.com/jhillyerd/enmime"
)
// swagger:parameters SendMessage
type sendMessageParams struct {
// in: body
Body *SendRequest
}
// SendRequest to send a message via HTTP
// swagger:model SendRequest
type SendRequest struct {
// "From" recipient
// required: true
From struct {
// Optional name
// example: John Doe
Name string
// Email address
// example: john@example.com
// required: true
Email string
}
// "To" recipients
To []struct {
// Optional name
// example: Jane Doe
Name string
// Email address
// example: jane@example.com
// required: true
Email string
}
// Cc recipients
Cc []struct {
// Optional name
// example: Manager
Name string
// Email address
// example: manager@example.com
// required: true
Email string
}
// Bcc recipients email addresses only
// example: ["jack@example.com"]
Bcc []string
// Optional Reply-To recipients
ReplyTo []struct {
// Optional name
// example: Secretary
Name string
// Email address
// example: secretary@example.com
// required: true
Email string
}
// Subject
// example: Mailpit message via the HTTP API
Subject string
// Message body (text)
// example: This is the text body
Text string
// Message body (HTML)
// example: <p style="font-family: arial">Mailpit is <b>awesome</b>!</p>
HTML string
// Attachments
Attachments []struct {
// Base64-encoded string of the file content
// required: true
// example: VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==
Content string
// Filename
// required: true
// example: AttachedFile.txt
Filename string
}
// Mailpit tags
// example: ["Tag 1","Tag 2"]
Tags []string
// Optional headers in {"key":"value"} format
// example: {"X-IP":"1.2.3.4"}
Headers map[string]string
}
// SendMessageConfirmation struct
type SendMessageConfirmation struct {
// Database ID
// example: iAfZVVe2UQFNSG5BAjgYwa
ID string
}
// JSONErrorMessage struct
type JSONErrorMessage struct {
// Error message
// example: invalid format
Error string
}
// SendMessageHandler handles HTTP requests to send a new message
func SendMessageHandler(w http.ResponseWriter, r *http.Request) {
// swagger:route POST /api/v1/send message SendMessage
//
// # Send a message
//
// Send a message via the HTTP API.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: sendMessageResponse
// default: jsonErrorResponse
decoder := json.NewDecoder(r.Body)
data := SendRequest{}
if err := decoder.Decode(&data); err != nil {
httpJSONError(w, err.Error())
return
}
id, err := data.Send(r.RemoteAddr)
if err != nil {
httpJSONError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(SendMessageConfirmation{ID: id}); err != nil {
httpError(w, err.Error())
}
}
// Send will validate the message structure and attempt to send to Mailpit.
// It returns a sending summary or an error.
func (d SendRequest) Send(remoteAddr string) (string, error) {
ip, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return "", fmt.Errorf("error parsing request RemoteAddr: %s", err.Error())
}
ipAddr := &net.IPAddr{IP: net.ParseIP(ip)}
addresses := []string{}
msg := enmime.Builder().
From(d.From.Name, d.From.Email).
Subject(d.Subject).
Text([]byte(d.Text))
if d.HTML != "" {
msg = msg.HTML([]byte(d.HTML))
}
if len(d.To) > 0 {
for _, a := range d.To {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.To(a.Name, a.Email)
addresses = append(addresses, a.Email)
} else {
return "", fmt.Errorf("invalid To address: %s", a.Email)
}
}
}
if len(d.Cc) > 0 {
for _, a := range d.Cc {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.CC(a.Name, a.Email)
addresses = append(addresses, a.Email)
} else {
return "", fmt.Errorf("invalid Cc address: %s", a.Email)
}
}
}
if len(d.Bcc) > 0 {
for _, e := range d.Bcc {
if _, err := mail.ParseAddress(e); err == nil {
msg = msg.BCC("", e)
addresses = append(addresses, e)
} else {
return "", fmt.Errorf("invalid Bcc address: %s", e)
}
}
}
if len(d.ReplyTo) > 0 {
for _, a := range d.ReplyTo {
if _, err := mail.ParseAddress(a.Email); err == nil {
msg = msg.ReplyTo(a.Name, a.Email)
} else {
return "", fmt.Errorf("invalid Reply-To address: %s", a.Email)
}
}
}
restrictedHeaders := []string{"To", "From", "Cc", "Bcc", "Reply-To", "Date", "Subject", "Content-Type", "Mime-Version"}
if len(d.Tags) > 0 {
msg = msg.Header("X-Tags", strings.Join(d.Tags, ", "))
restrictedHeaders = append(restrictedHeaders, "X-Tags")
}
if len(d.Headers) > 0 {
for k, v := range d.Headers {
// check header isn't in "restricted" headers
if tools.InArray(k, restrictedHeaders) {
return "", fmt.Errorf("cannot overwrite header: \"%s\"", k)
}
msg = msg.Header(k, v)
}
}
if len(d.Attachments) > 0 {
for _, a := range d.Attachments {
// workaround: split string because JS readAsDataURL() returns the base64 string
// with the mime type prefix eg: data:image/png;base64,<base64String>
parts := strings.Split(a.Content, ",")
content := parts[len(parts)-1]
b, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return "", fmt.Errorf("error decoding base64 attachment \"%s\": %s", a.Filename, err.Error())
}
mimeType := http.DetectContentType(b)
msg = msg.AddAttachment(b, mimeType, a.Filename)
}
}
part, err := msg.Build()
if err != nil {
return "", fmt.Errorf("error building message: %s", err.Error())
}
var buff bytes.Buffer
if err := part.Encode(io.Writer(&buff)); err != nil {
return "", fmt.Errorf("error building message: %s", err.Error())
}
return smtpd.Store(ipAddr, d.From.Email, addresses, buff.Bytes())
}

View File

@@ -47,7 +47,7 @@ type deleteMessagesRequestBody struct {
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
IDs []string
}
// swagger:parameters SetReadStatus
@@ -64,13 +64,13 @@ type setReadStatusRequestBody struct {
// required: false
// default: false
// example: true
Read bool `json:"read"`
Read bool
// Array of message database IDs
//
// required: false
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
IDs []string
}
// swagger:parameters SetTags
@@ -86,13 +86,13 @@ type setTagsRequestBody struct {
//
// required: true
// example: ["Tag 1", "Tag 2"]
Tags []string `json:"tags"`
Tags []string
// Array of message database IDs
//
// required: true
// example: ["5dec4247-812e-4b77-9101-e25ad406e9ea", "8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"]
IDs []string `json:"ids"`
IDs []string
}
// swagger:parameters ReleaseMessage
@@ -112,10 +112,9 @@ type releaseMessageParams struct {
// swagger:model releaseMessageRequestBody
type releaseMessageRequestBody struct {
// Array of email addresses to relay the message to
//
// required: true
// example: ["user1@example.com", "user2@example.com"]
To []string `json:"to"`
To []string
}
// swagger:parameters HTMLCheck
@@ -156,7 +155,7 @@ type spamAssassinCheckParams struct {
ID string
}
// Binary data response inherits the attachment's content type
// Binary data response inherits the attachment's content type.
// swagger:response BinaryResponse
type binaryResponse string
@@ -170,6 +169,7 @@ type htmlResponse string
// HTTP error response will return with a >= 400 response code
// swagger:response ErrorResponse
// example: invalid request
type errorResponse string
// Plain text "ok" response
@@ -179,3 +179,21 @@ type okResponse string
// Plain JSON array response
// swagger:response ArrayResponse
type arrayResponse []string
// Confirmation message for HTTP send API
// swagger:response sendMessageResponse
type sendMessageResponse struct {
// Response for sending messages via the HTTP API
//
// in: body
Body SendMessageConfirmation
}
// JSON error response
// swagger:response jsonErrorResponse
type jsonErrorResponse struct {
// A JSON-encoded error response
//
// in: body
Body JSONErrorMessage
}

View File

@@ -65,8 +65,8 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
conf.SpamAssassin = config.EnableSpamAssassin != ""
conf.DuplicatesIgnored = config.IgnoreDuplicateIDs
bytes, _ := json.Marshal(conf)
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes)
if err := json.NewEncoder(w).Encode(conf); err != nil {
httpError(w, err.Error())
}
}

View File

@@ -127,10 +127,11 @@ func apiRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.GetMessages)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.SetReadStatus)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
r.HandleFunc(config.Webroot+"api/v1/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}", middleWareFunc(apiv1.DownloadAttachment)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/part/{partID}/thumb", middleWareFunc(apiv1.Thumbnail)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
@@ -323,8 +324,6 @@ func index(w http.ResponseWriter, _ *http.Request) {
panic(err)
}
buff.Bytes()
w.Header().Add("Content-Type", "text/html")
_, _ = w.Write(buff.Bytes())
}

View File

@@ -21,8 +21,8 @@ import (
var (
putDataStruct struct {
Read bool `json:"read"`
IDs []string `json:"ids"`
Read bool
IDs []string
}
)
@@ -202,6 +202,106 @@ func TestAPIv1Search(t *testing.T) {
assertSearchEqual(t, ts.URL+"/api/v1/search", "!tag:\"Test tag 023\"", 99)
}
func TestAPIv1Send(t *testing.T) {
setup()
defer storage.Close()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
jsonData := `{
"From": {
"Email": "john@example.com",
"Name": "John Doe"
},
"To": [
{
"Email": "jane@example.com",
"Name": "Jane Doe"
}
],
"Cc": [
{
"Email": "manager1@example.com",
"Name": "Manager 1"
},
{
"Email": "manager2@example.com",
"Name": "Manager 2"
}
],
"Bcc": ["jack@example.com"],
"Headers": {
"X-IP": "1.2.3.4"
},
"Subject": "Mailpit message via the HTTP API",
"Text": "This is the text body",
"HTML": "<p style=\"font-family: arial\">Mailpit is <b>awesome</b>!</p>",
"Attachments": [
{
"Content": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA==",
"Filename": "Attached File.txt"
}
],
"ReplyTo": [
{
"Email": "secretary@example.com",
"Name": "Secretary"
}
],
"Tags": [
"Tag 1",
"Tag 2"
]
}`
t.Log("Sending message via HTTP API")
b, err := clientPost(ts.URL+"/api/v1/send", jsonData)
if err != nil {
t.Errorf("Expected nil, received %s", err.Error())
}
resp := apiv1.SendMessageConfirmation{}
if err := json.Unmarshal(b, &resp); err != nil {
t.Errorf(err.Error())
return
}
t.Logf("Fetching response for message %s", resp.ID)
msg, err := fetchMessage(ts.URL + "/api/v1/message/" + resp.ID)
if err != nil {
t.Errorf(err.Error())
}
t.Logf("Testing response for message %s", resp.ID)
assertEqual(t, `Mailpit message via the HTTP API`, msg.Subject, "wrong subject")
assertEqual(t, `This is the text body`, msg.Text, "wrong text")
assertEqual(t, `<p style="font-family: arial">Mailpit is <b>awesome</b>!</p>`, msg.HTML, "wrong HTML")
assertEqual(t, `"John Doe" <john@example.com>`, msg.From.String(), "wrong HTML")
assertEqual(t, 1, len(msg.To), "wrong To count")
assertEqual(t, `"Jane Doe" <jane@example.com>`, msg.To[0].String(), "wrong To address")
assertEqual(t, 2, len(msg.Cc), "wrong Cc count")
assertEqual(t, `"Manager 1" <manager1@example.com>`, msg.Cc[0].String(), "wrong Cc address")
assertEqual(t, `"Manager 2" <manager2@example.com>`, msg.Cc[1].String(), "wrong Cc address")
assertEqual(t, 1, len(msg.Bcc), "wrong Bcc count")
assertEqual(t, `<jack@example.com>`, msg.Bcc[0].String(), "wrong Bcc address")
assertEqual(t, 1, len(msg.ReplyTo), "wrong Reply-To count")
assertEqual(t, `"Secretary" <secretary@example.com>`, msg.ReplyTo[0].String(), "wrong Reply-To address")
assertEqual(t, 2, len(msg.Tags), "wrong Tags count")
assertEqual(t, `Tag 1,Tag 2`, strings.Join(msg.Tags, ","), "wrong Tags")
assertEqual(t, 1, len(msg.Attachments), "wrong Attachment count")
assertEqual(t, `Attached File.txt`, msg.Attachments[0].FileName, "wrong Attachment name")
attachmentBytes, err := clientGet(ts.URL + "/api/v1/message/" + resp.ID + "/part/" + msg.Attachments[0].PartID)
if err != nil {
t.Errorf(err.Error())
}
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
}
func setup() {
logger.NoLogging = true
config.MaxMessages = 0
@@ -288,7 +388,21 @@ func insertEmailData(t *testing.T) {
t.Fail()
}
}
}
func fetchMessage(url string) (storage.Message, error) {
m := storage.Message{}
data, err := clientGet(url)
if err != nil {
return m, err
}
if err := json.Unmarshal(data, &m); err != nil {
return m, err
}
return m, nil
}
func fetchMessages(url string) (apiv1.MessagesSummary, error) {
@@ -372,6 +486,31 @@ func clientPut(url, body string) ([]byte, error) {
return data, err
}
func clientPost(url, body string) ([]byte, error) {
client := new(http.Client)
b := strings.NewReader(body)
req, err := http.NewRequest("POST", url, b)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
return data, err
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return

View File

@@ -23,7 +23,13 @@ var (
DisableReverseDNS bool
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
// MailHandler handles the incoming message to store in the database
func mailHandler(origin net.Addr, from string, to []string, data []byte) (string, error) {
return Store(origin, from, to, data)
}
// Store will attempt to save a message to the database
func Store(origin net.Addr, from string, to []string, data []byte) (string, error) {
if !config.SMTPStrictRFCHeaders {
// replace all <CR><CR><LF> (\r\r\n) with <CR><LF> (\r\n)
// @see https://github.com/axllent/mailpit/issues/87 & https://github.com/axllent/mailpit/issues/153
@@ -34,7 +40,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
if err != nil {
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
stats.LogSMTPRejected()
return err
return "", err
}
// check / set the Return-Path based on SMTP from
@@ -70,7 +76,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
if storage.MessageIDExists(messageID) {
logger.Log().Debugf("[smtpd] duplicate message found, ignoring %s", messageID)
stats.LogSMTPIgnored()
return nil
return "", nil
}
}
@@ -117,10 +123,10 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
logger.Log().Debugf("[smtpd] added missing addresses to Bcc header: %s", strings.Join(missingAddresses, ", "))
}
_, err = storage.Store(&data)
id, err := storage.Store(&data)
if err != nil {
logger.Log().Errorf("[db] error storing message: %s", err.Error())
return err
return "", err
}
stats.LogSMTPAccepted(len(data))
@@ -130,7 +136,7 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtpd] received (%s) from:%s subject:%q", cleanIP(origin), from, subject)
return nil
return id, err
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, _ []byte) (bool, error) {
@@ -204,10 +210,10 @@ func Listen() error {
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
Addr: addr,
Handler: handler,
MsgIDHandler: handler,
HandlerRcpt: handlerRcpt,
Appname: "Mailpit",
Hostname: "",

View File

@@ -1,7 +1,7 @@
<script>
import { mailbox } from '../stores/mailbox'
import CommonMixins from '../mixins/CommonMixins'
import moment from 'moment'
import dayjs from 'dayjs'
export default {
mixins: [
@@ -19,32 +19,26 @@ export default {
},
mounted() {
moment.updateLocale('en', {
relativeTime: {
future: "in %s",
past: "%s ago",
s: 'seconds',
ss: '%d secs',
m: "a minute",
mm: "%d mins",
h: "an hour",
hh: "%d hours",
d: "a day",
dd: "%d days",
w: "a week",
ww: "%d weeks",
M: "a month",
MM: "%d months",
y: "a year",
yy: "%d years"
}
})
let relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
this.refreshUI()
},
methods: {
refreshUI: function () {
let self = this
window.setTimeout(
() => {
self.$forceUpdate()
self.refreshUI()
},
30000
)
},
getRelativeCreated: function (message) {
let d = new Date(message.Created)
return moment(d).fromNow().toString()
return dayjs(d).fromNow()
},
getPrimaryEmailTo: function (message) {
@@ -112,7 +106,8 @@ export default {
<template>
<template v-if="mailbox.messages && mailbox.messages.length">
<div class="list-group my-2">
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID" :id="message.ID"
<RouterLink v-for="message in mailbox.messages" :to="'/view/' + message.ID" :key="message.ID"
:id="message.ID"
class="row gx-1 message d-flex small list-group-item list-group-item-action border-start-0 border-end-0"
:class="message.Read ? 'read' : '', isSelected(message.ID) ? 'selected' : ''"
v-on:click.ctrl="toggleSelected($event, message.ID)" v-on:click.shift="selectRange($event, message.ID)">
@@ -123,15 +118,15 @@ export default {
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</span>
message.From.Name ?
message.From.Name : message.From.Address
}}</span>
</div>
<div class="text-truncate d-none d-lg-block privacy">
<b v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</b>
message.From.Name ?
message.From.Name : message.From.Address
}}</b>
</div>
<div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }}

View File

@@ -29,7 +29,7 @@ export default {
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'read': true, 'ids': mailbox.selected }, function (response) {
self.put(self.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
@@ -45,7 +45,7 @@ export default {
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'read': false, 'ids': mailbox.selected }, function (response) {
self.put(self.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
@@ -59,7 +59,7 @@ export default {
if (!ids.length) {
return false
}
self.delete(self.resolve(`/api/v1/messages`), { 'ids': ids }, function (response) {
self.delete(self.resolve(`/api/v1/messages`), { 'IDs': ids }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})

View File

@@ -64,9 +64,11 @@ export default {
}
for (let i in response.Data.Tags) {
if (mailbox.tags.indexOf(response.Data.Tags[i]) < 0) {
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
mailbox.tags.push(response.Data.Tags[i])
mailbox.tags.sort()
mailbox.tags.sort((a, b) => {
return a.toLowerCase().localeCompare(b.toLowerCase())
})
}
}

View File

@@ -70,9 +70,9 @@ export default {
},
computed: {
hasAnyChecksEnabled: function() {
hasAnyChecksEnabled: function () {
return (mailbox.showHTMLCheck && this.message.HTML)
|| mailbox.showLinkCheck
|| mailbox.showLinkCheck
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
}
},
@@ -198,8 +198,8 @@ export default {
let self = this
var data = {
ids: [this.message.ID],
tags: this.messageTags
IDs: [this.message.ID],
Tags: this.messageTags
}
self.put(self.resolve('/api/v1/tags'), data, function (response) {
@@ -476,8 +476,7 @@ export default {
</div>
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab"
aria-controls="nav-html" aria-selected="false"
v-if="mailbox.showHTMLCheck && message.HTML != ''">
aria-controls="nav-html" aria-selected="false" v-if="mailbox.showHTMLCheck && message.HTML != ''">
HTML Check
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
<small>{{ Math.floor(htmlScore) }}%</small>
@@ -494,7 +493,8 @@ export default {
</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.showSpamCheck && mailbox.uiConfig.SpamAssassin">
aria-controls="nav-html" aria-selected="false"
v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
Spam Analysis
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
<small>{{ spamScore }}</small>
@@ -545,13 +545,13 @@ export default {
</div>
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
tabindex="0">
<HTMLCheck v-if="mailbox.showHTMLCheck && message.HTML != ''"
:message="message" @setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
<HTMLCheck v-if="mailbox.showHTMLCheck && message.HTML != ''" :message="message"
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
</div>
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
tabindex="0" v-if="mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin">
<SpamAssassin :message="message"
@setSpamScore="(n) => spamScore = n" @set-badge-style="(v) => spamScoreColor = v" />
<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"
tabindex="0" v-if="mailbox.showLinkCheck">

View File

@@ -1,4 +1,3 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import Tags from "bootstrap5-tags"
@@ -60,7 +59,7 @@ export default {
}
let data = {
to: self.addresses
To: self.addresses
}
self.post(self.resolve('/api/v1/message/' + self.message.ID + '/release'), data, function (response) {
@@ -103,8 +102,9 @@ export default {
<label class="col-sm-2 col-form-label text-body-secondary">Send to</label>
<div class="col-sm-10">
<select class="form-select tag-selector" v-model="addresses" multiple data-allow-new="true"
data-clear-end="true" data-allow-clear="true" data-placeholder="Enter email addresses..."
data-add-on-blur="true" data-badge-style="primary"
data-clear-end="true" data-allow-clear="true"
data-placeholder="Enter email addresses..." data-add-on-blur="true"
data-badge-style="primary"
data-regex='^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'
data-separator="|,|">
<option value="">Enter email addresses...</option>
@@ -127,16 +127,17 @@ export default {
</div>
</div>
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.AllowedRecipients != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
<br class="d-none d-md-inline">
Note: A recipient allowlist has been configured. Any mail address not matching it will be
rejected.<br class="d-none d-md-inline">
Allowed recipients: <b>{{ mailbox.uiConfig.MessageRelay.AllowedRecipients }}</b>
</div>
<div class="form-text text-center">
Note: For testing purposes, a unique Message-Id will be generated on send.
<br class="d-none d-md-inline">
SMTP delivery failures will bounce back to
<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">{{ mailbox.uiConfig.MessageRelay.ReturnPath
}}</b>
<b v-if="mailbox.uiConfig.MessageRelay.ReturnPath != ''">
{{ mailbox.uiConfig.MessageRelay.ReturnPath }}
</b>
<b v-else>{{ message.ReturnPath }}</b>.
</div>
</div>

View File

@@ -125,7 +125,7 @@ export default {
]
},
scoreColor: function() {
scoreColor: function () {
return this.graphSections[0].color
},
}
@@ -206,10 +206,6 @@ export default {
<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">
@@ -218,13 +214,14 @@ export default {
What is Spam Analysis?
</button>
</h2>
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
<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.
attachments) to a running SpamAssassin server and then displays the results
returned by SpamAssassin.
</p>
</div>
</div>
@@ -236,16 +233,17 @@ export default {
How does the point system work?
</button>
</h2>
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
<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.
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>
@@ -257,7 +255,8 @@ export default {
But I don't agree with the results...
</button>
</h2>
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
<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
@@ -265,8 +264,9 @@ export default {
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.
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>
@@ -278,14 +278,15 @@ export default {
Where can I find more information about the triggered rules?
</button>
</h2>
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#SpamAnalysisAboutAccordion">
<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.
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>

View File

@@ -1,5 +1,5 @@
import axios from 'axios'
import moment from 'moment'
import dayjs from 'dayjs'
import ColorHash from 'color-hash'
import { Modal, Offcanvas } from 'bootstrap'
@@ -45,11 +45,11 @@ export default {
},
messageDate: function (d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a')
return dayjs(d).format('ddd, D MMM YYYY, h:mm a')
},
secondsToRelative: function (d) {
return moment().subtract(d, 'seconds').fromNow()
return dayjs().subtract(d, 'seconds').fromNow()
},
tagEncodeURI: function (tag) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -135,7 +135,7 @@
"tags": [
"Other"
],
"summary": "HTML check (beta)",
"summary": "HTML check",
"operationId": "HTMLCheck",
"parameters": [
{
@@ -172,7 +172,7 @@
"tags": [
"Other"
],
"summary": "Link check (beta)",
"summary": "Link check",
"operationId": "LinkCheck",
"parameters": [
{
@@ -368,7 +368,7 @@
},
"/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.",
"description": "Returns the SpamAssassin summary (if enabled) of the message.",
"produces": [
"application/json"
],
@@ -379,7 +379,7 @@
"tags": [
"Other"
],
"summary": "SpamAssassin check (beta)",
"summary": "SpamAssassin check",
"operationId": "SpamAssassinCheck",
"parameters": [
{
@@ -606,6 +606,43 @@
}
}
},
"/api/v1/send": {
"post": {
"description": "Send a message via the HTTP API.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"tags": [
"message"
],
"summary": "Send a message",
"operationId": "SendMessage",
"parameters": [
{
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/SendRequest"
}
}
],
"responses": {
"200": {
"$ref": "#/responses/sendMessageResponse"
},
"default": {
"$ref": "#/responses/jsonErrorResponse"
}
}
}
},
"/api/v1/tags": {
"get": {
"description": "Returns a JSON array of all unique message tags.",
@@ -890,13 +927,12 @@
"description": "Delete request",
"type": "object",
"properties": {
"ids": {
"IDs": {
"description": "Array of message database IDs",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "IDs",
"example": [
"5dec4247-812e-4b77-9101-e25ad406e9ea",
"8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"
@@ -1083,6 +1119,18 @@
"x-go-name": "Warning",
"x-go-package": "github.com/axllent/mailpit/internal/htmlcheck"
},
"JSONErrorMessage": {
"description": "JSONErrorMessage struct",
"type": "object",
"properties": {
"Error": {
"description": "Error message",
"type": "string",
"example": "invalid format"
}
},
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"Link": {
"description": "Link struct",
"type": "object",
@@ -1375,6 +1423,182 @@
},
"x-go-package": "github.com/axllent/mailpit/internal/spamassassin"
},
"SendMessageConfirmation": {
"description": "SendMessageConfirmation struct",
"type": "object",
"properties": {
"ID": {
"description": "Database ID",
"type": "string",
"example": "iAfZVVe2UQFNSG5BAjgYwa"
}
},
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"SendRequest": {
"description": "SendRequest to send a message via HTTP",
"type": "object",
"required": [
"From"
],
"properties": {
"Attachments": {
"description": "Attachments",
"type": "array",
"items": {
"type": "object",
"required": [
"Content",
"Filename"
],
"properties": {
"Content": {
"description": "Base64-encoded string of the file content",
"type": "string",
"example": "VGhpcyBpcyBhIHBsYWluIHRleHQgYXR0YWNobWVudA=="
},
"Filename": {
"description": "Filename",
"type": "string",
"example": "AttachedFile.txt"
}
}
}
},
"Bcc": {
"description": "Bcc recipients email addresses only",
"type": "array",
"items": {
"type": "string"
},
"example": [
"jack@example.com"
]
},
"Cc": {
"description": "Cc recipients",
"type": "array",
"items": {
"type": "object",
"required": [
"Email"
],
"properties": {
"Email": {
"description": "Email address",
"type": "string",
"example": "manager@example.com"
},
"Name": {
"description": "Optional name",
"type": "string",
"example": "Manager"
}
}
}
},
"From": {
"description": "\"From\" recipient",
"type": "object",
"required": [
"Email"
],
"properties": {
"Email": {
"description": "Email address",
"type": "string",
"example": "john@example.com"
},
"Name": {
"description": "Optional name",
"type": "string",
"example": "John Doe"
}
}
},
"HTML": {
"description": "Message body (HTML)",
"type": "string",
"example": "\u003cp style=\"font-family: arial\"\u003eMailpit is \u003cb\u003eawesome\u003c/b\u003e!\u003c/p\u003e"
},
"Headers": {
"description": "Optional headers in {\"key\":\"value\"} format",
"type": "object",
"additionalProperties": {
"type": "string"
},
"example": {
"X-IP": "1.2.3.4"
}
},
"ReplyTo": {
"description": "Optional Reply-To recipients",
"type": "array",
"items": {
"type": "object",
"required": [
"Email"
],
"properties": {
"Email": {
"description": "Email address",
"type": "string",
"example": "secretary@example.com"
},
"Name": {
"description": "Optional name",
"type": "string",
"example": "Secretary"
}
}
}
},
"Subject": {
"description": "Subject",
"type": "string",
"example": "Mailpit message via the HTTP API"
},
"Tags": {
"description": "Mailpit tags",
"type": "array",
"items": {
"type": "string"
},
"example": [
"Tag 1",
"Tag 2"
]
},
"Text": {
"description": "Message body (text)",
"type": "string",
"example": "This is the text body"
},
"To": {
"description": "\"To\" recipients",
"type": "array",
"items": {
"type": "object",
"required": [
"Email"
],
"properties": {
"Email": {
"description": "Email address",
"type": "string",
"example": "jane@example.com"
},
"Name": {
"description": "Optional name",
"type": "string",
"example": "Jane Doe"
}
}
}
}
},
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
},
"SpamAssassinResponse": {
"description": "Result is a SpamAssassin result",
"type": "object",
@@ -1445,16 +1669,15 @@
"description": "Release request",
"type": "object",
"required": [
"to"
"To"
],
"properties": {
"to": {
"To": {
"description": "Array of email addresses to relay the message to",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "To",
"example": [
"user1@example.com",
"user2@example.com"
@@ -1467,23 +1690,21 @@
"description": "Set read status request",
"type": "object",
"properties": {
"ids": {
"IDs": {
"description": "Array of message database IDs",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "IDs",
"example": [
"5dec4247-812e-4b77-9101-e25ad406e9ea",
"8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"
]
},
"read": {
"Read": {
"description": "Read status",
"type": "boolean",
"default": false,
"x-go-name": "Read",
"example": true
}
},
@@ -1493,29 +1714,27 @@
"description": "Set tags request",
"type": "object",
"required": [
"tags",
"ids"
"Tags",
"IDs"
],
"properties": {
"ids": {
"IDs": {
"description": "Array of message database IDs",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "IDs",
"example": [
"5dec4247-812e-4b77-9101-e25ad406e9ea",
"8ac66bbc-2d9a-4c41-ad99-00aa75fa674e"
]
},
"tags": {
"Tags": {
"description": "Array of tag names to set",
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "Tags",
"example": [
"Tag 1",
"Tag 2"
@@ -1536,7 +1755,7 @@
}
},
"BinaryResponse": {
"description": "Binary data response inherits the attachment's content type",
"description": "Binary data response inherits the attachment's content type.",
"schema": {
"type": "string"
}
@@ -1582,6 +1801,18 @@
"schema": {
"$ref": "#/definitions/WebUIConfiguration"
}
},
"jsonErrorResponse": {
"description": "JSON error response",
"schema": {
"$ref": "#/definitions/JSONErrorMessage"
}
},
"sendMessageResponse": {
"description": "Confirmation message for HTTP send API",
"schema": {
"$ref": "#/definitions/SendMessageConfirmation"
}
}
}
}