1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-08-15 20:13:16 +02:00

Merge branch 'release/v1.20.0'

This commit is contained in:
Ralph Slooten
2024-08-06 18:58:12 +12:00
30 changed files with 924 additions and 300 deletions

View File

@@ -2,6 +2,25 @@
Notable changes to Mailpit will be documented in this file.
## [v1.20.0]
### Feature
- Add option to control message retention by age ([#338](https://github.com/axllent/mailpit/issues/338))
- **UI:** List messages in side nav when viewing message for easy navigation ([#336](https://github.com/axllent/mailpit/issues/336))
### Chore
- Update caniemail database
- Update Go dependencies
- Update node dependencies
- Make internal tagging methods private
### Fix
- Prevent potential JavaScript errors caused by race condition
- Better regexp to detect tags in search
- Prevent Vue race condition to initialize dayjs relativeTime plugin
- **API:** Return `text/plain` header for message delete request
## [v1.19.3]
### Chore

View File

@@ -85,6 +85,7 @@ func init() {
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
@@ -179,6 +180,9 @@ func initConfigFromEnv() {
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
if len(os.Getenv("MP_MAX_AGE")) > 0 {
config.MaxAge = os.Getenv("MP_MAX_AGE")
}
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
config.UseMessageDates = true
}

View File

@@ -10,6 +10,7 @@ import (
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/axllent/mailpit/internal/auth"
@@ -31,15 +32,22 @@ var (
// TenantID is an optional prefix to be applied to all database tables,
// allowing multiple isolated instances of Mailpit to share a database.
TenantID = ""
TenantID string
// Label to identify this Mailpit instance (optional).
// This gets applied to web UI, SMTP and optional POP3 server.
Label = ""
Label string
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
// MaxAge is the maximum age of messages (auto-pruned every hour).
// Value can be either <int>h for hours or <int>d for days
MaxAge string
// MaxAgeInHours is the maximum age of messages in hours, set with parseMaxAge() using MaxAge value
MaxAgeInHours int
// UseMessageDates sets the Created date using the message date, not the delivered date
UseMessageDates bool
@@ -218,6 +226,10 @@ func VerifyConfig() error {
Label = tools.Normalize(Label)
if err := parseMaxAge(); err != nil {
return err
}
TenantID = tools.Normalize(TenantID)
if TenantID != "" {
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
@@ -458,6 +470,39 @@ func VerifyConfig() error {
return nil
}
// Parse the --max-age value (if set)
func parseMaxAge() error {
if MaxAge == "" {
return nil
}
re := regexp.MustCompile(`^\d+(h|d)$`)
if !re.MatchString(MaxAge) {
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
}
if strings.HasSuffix(MaxAge, "h") {
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
if err != nil {
return err
}
MaxAgeInHours = hours
return nil
}
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
if err != nil {
return err
}
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
MaxAgeInHours = days * 24
return nil
}
// Parse the SMTPRelayConfigFile (if set)
func parseRelayConfig(c string) error {
if c == "" {

10
go.mod
View File

@@ -8,7 +8,7 @@ require (
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-20240723152757-afa4a469d4f9
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/jhillyerd/enmime v1.2.0
@@ -26,7 +26,7 @@ require (
github.com/vanng822/go-premailer v1.21.0
golang.org/x/net v0.27.0
golang.org/x/text v0.16.0
golang.org/x/time v0.5.0
golang.org/x/time v0.6.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.31.1
)
@@ -56,10 +56,10 @@ require (
github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/sys v0.23.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e // indirect
modernc.org/libc v1.55.4 // indirect
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
modernc.org/libc v1.56.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect

28
go.sum
View File

@@ -23,8 +23,8 @@ github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gomarkdown/markdown v0.0.0-20240723152757-afa4a469d4f9 h1:TRYrIWJziqvMVn1owO8bmkDJTlMQFYnf74yhD8LXfgU=
github.com/gomarkdown/markdown v0.0.0-20240723152757-afa4a469d4f9/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 h1:ZPy+2XJ8u0bB3sNFi+I72gMEMS7MTg7aZCCXPOjV8iw=
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -161,8 +161,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.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=
@@ -178,8 +178,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
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/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -197,16 +197,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.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.20.4 h1:3pPOlMcblnu5CBU3w1BFtepwBnLezGjPYTH8xBeYZM8=
modernc.org/ccgo/v4 v4.20.4/go.mod h1:meYiLeaGpKQmHBw8roW4DXLkDvusG+MD7LJ/kYyAouU=
modernc.org/ccgo/v4 v4.20.6 h1:Df/8U75vr6+vqYyBCSGXinSbkl/0iLjWEIYGYgYhJOk=
modernc.org/ccgo/v4 v4.20.6/go.mod h1:mL6ov9HLsSs3WlPorlPuWFO5u9+xVNTqW72uClt3pRI=
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.3 h1:Ik4ZcMbC7aY4ZDPUhzXVXi7GMub9QcXLTfXn3mWpNw8=
modernc.org/gc/v2 v2.4.3/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e h1:WPC4v0rNIFb2PY+nBBEEKyugPPRHPzUgyN3xZPpGK58=
modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.4 h1:iWzZ96v1Iut2Sm4lEp0yzFde20M9zpcI0wY3DFcb+8g=
modernc.org/libc v1.55.4/go.mod h1:GPuVtbWvXUo590z/xfVIQcOqnugb1WovSfMJWMe0ZfA=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.56.0 h1:O9Xq2c2MnP1bU69vbPX5jHQyQfElMprUR94GlBljRaw=
modernc.org/libc v1.56.0/go.mod h1:s9jnOo7LcO1G9+nGj4GY/+Fj8+teenJoC/WfVxnSd+M=
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=

View File

@@ -1,6 +1,6 @@
{
"api_version":"1.0.4",
"last_update_date":"2024-05-30 19:50:57 +0000",
"last_update_date":"2024-07-29 15:27:49 +0000",
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
"data":[
{
@@ -2990,9 +2990,9 @@
"last_test_date":"2020-02-25",
"test_url":"https://www.caniemail.com/tests/css-units.html",
"test_results_url":"",
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"},"mobile-webmail":{"2020-02":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"n","2024-04":"n"},"ios":{"2020-02":"y","2024-04":"n"},"android":{"2020-02":"y","2024-04":"n"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"web-de":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-08":"y"},"android":{"2022-08":"y"}}},
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"},"mobile-webmail":{"2020-02":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y #1","2024-01":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"n","2024-04":"n"},"ios":{"2020-02":"y","2024-04":"n"},"android":{"2020-02":"y","2024-04":"n"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #1"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"web-de":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-08":"y"},"android":{"2022-08":"y"}}},
"notes":"",
"notes_by_num":{}
"notes_by_num":{"1":"The HTML of the email message is embedded directly on the webmail (not in an <iframe>) and may not fill the full viewport's width. In this case, the vw values are relevant to the viewport (browser window) not the email message."}
},
{
@@ -3931,12 +3931,12 @@
"category":"html",
"tags":["accessibility","performance"],
"keywords":"picture, responsive image",
"last_test_date":"2019-05-29",
"last_test_date":"2024-04-15",
"test_url":"https://www.caniemail.com/tests/html-picture.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/AQoLHTLaC6F6JcMrkx38M7oyiJlAlXeRnJgkK06bSJiBR/list",
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-05":"n #1"},"ios":{"2019-05":"n #1"},"android":{"2019-05":"n #1"},"mobile-webmail":{"2020-02":"n #1"}},"orange":{"desktop-webmail":{"2019-05":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-05":"y","2024-04":"n"},"android":{"2019-05":"y","2024-04":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.62":"n","16.80":"n"},"outlook-com":{"2019-05":"n","2024-01":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-05":"y"},"ios":{"2019-05":"y"},"android":{"2019-05":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-05":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"n"},"android":{"2022-11":"n"}}},
"test_results_url":"https://testi.at/proj/vr32cxxk1exntxrjfdp",
"stats":{"apple-mail":{"macos":{"10.3":"y","10.15":"a #2","11.7":"a #2","12.7":"a #2","13.6":"a #2","14.4":"a #2"},"ios":{"10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-05":"n #1"},"ios":{"2019-05":"n #1"},"android":{"2019-05":"n #1"},"mobile-webmail":{"2020-02":"n #1"}},"orange":{"desktop-webmail":{"2019-05":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-05":"y","2024-04":"n"},"android":{"2019-05":"y","2024-04":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.62":"n","16.80":"n"},"outlook-com":{"2019-05":"n","2024-01":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-05":"y"},"ios":{"2019-05":"y"},"android":{"2019-05":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-05":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"n"},"android":{"2022-11":"n"}}},
"notes":"",
"notes_by_num":{"1":"`<picture>` and `<source>` tags are replaced by `<u></u>` tags."}
"notes_by_num":{"1":"`<picture>` and `<source>` tags are replaced by `<u></u>` tags.","2":"`<picture>` tag is stripped in some cases (like having too few content or no background-color)."}
},
{
@@ -4510,7 +4510,7 @@
"last_test_date":"2023-01-15",
"test_url":"https://www.caniemail.com/tests/images.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list",
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n","2024-07":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null,
"notes_by_num":{"1":"Partially supported. Only works with non Google accounts."}
},

View File

@@ -9,6 +9,7 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
@@ -48,34 +49,67 @@ func dbCron() {
// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.
// Set config.MaxMessages to 0 to disable.
func pruneMessages() {
if config.MaxMessages < 1 {
if config.MaxMessages < 1 && config.MaxAgeInHours == 0 {
return
}
start := time.Now()
q := sqlf.Select("ID, Size").
From(tenant("mailbox")).
OrderBy("Created DESC").
Limit(5000).
Offset(config.MaxMessages)
ids := []string{}
var prunedSize int64
var size float64
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
// prune using `--max` if set
if config.MaxMessages > 0 {
q := sqlf.Select("ID, Size").
From(tenant("mailbox")).
OrderBy("Created DESC").
Limit(5000).
Offset(config.MaxMessages)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
}
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
// prune using `--max-age` if set
if config.MaxAgeInHours > 0 {
// now() minus the number of hours
ts := time.Now().Add(time.Duration(-config.MaxAgeInHours) * time.Hour).UnixMilli()
q := sqlf.Select("ID, Size").
From(tenant("mailbox")).
Where("Created < ?", ts).
Limit(5000)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id, &size); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
if !tools.InArray(id, ids) {
ids = append(ids, id)
prunedSize = prunedSize + int64(size)
}
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
}
if len(ids) == 0 {

View File

@@ -165,7 +165,7 @@ func Store(body *[]byte) (string, error) {
// List returns a subset of messages from the mailbox,
// sorted latest to oldest
func List(start, limit int) ([]MessageSummary, error) {
func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
results := []MessageSummary{}
tsStart := time.Now()
@@ -175,6 +175,10 @@ func List(start, limit int) ([]MessageSummary, error) {
Limit(limit).
Offset(start)
if beforeTS > 0 {
q = q.Where("Created < ?", beforeTS)
}
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created float64
var id string
@@ -428,12 +432,12 @@ func LatestID(r *http.Request) (string, error) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" {
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 1)
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 0, 1)
if err != nil {
return "", err
}
} else {
messages, err = List(0, 1)
messages, err = List(0, 0, 1)
if err != nil {
return "", err
}
@@ -462,6 +466,13 @@ func MarkRead(id string) error {
BroadcastMailboxStats()
d := struct {
ID string
Read bool
}{ID: id, Read: true}
websockets.Broadcast("update", d)
return err
}
@@ -534,6 +545,13 @@ func MarkUnread(id string) error {
BroadcastMailboxStats()
d := struct {
ID string
Read bool
}{ID: id, Read: false}
websockets.Broadcast("update", d)
return err
}
@@ -621,6 +639,15 @@ func DeleteMessages(ids []string) error {
BroadcastMailboxStats()
// broadcast individual message deletions
for _, id := range toDelete {
d := struct {
ID string
}{ID: id}
websockets.Broadcast("delete", d)
}
return nil
}
@@ -671,8 +698,9 @@ func DeleteAllMessages() error {
logMessagesDeleted(total)
websockets.Broadcast("prune", nil)
BroadcastMailboxStats()
websockets.Broadcast("truncate", nil)
return err
}

View File

@@ -122,7 +122,7 @@ func TestMessageSummary(t *testing.T) {
t.Fail()
}
summaries, err := List(0, 1)
summaries, err := List(0, 0, 1)
if err != nil {
t.Log("error ", err)
t.Fail()

View File

@@ -18,7 +18,7 @@ import (
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search, timezone string, start, limit int) ([]MessageSummary, int, error) {
func Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
@@ -28,6 +28,11 @@ func Search(search, timezone string, start, limit int) ([]MessageSummary, int, e
}
q := searchQueryBuilder(search, timezone)
if beforeTS > 0 {
q = q.Where(`Created < ?`, beforeTS)
}
var err error
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {

View File

@@ -69,7 +69,7 @@ func TestSearch(t *testing.T) {
search := uniqueSearches[searchIdx]
summaries, _, err := Search(search, "", 0, 100)
summaries, _, err := Search(search, "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -85,7 +85,7 @@ func TestSearch(t *testing.T) {
}
// search something that will return 200 results
summaries, _, err := Search("This is the email body", "", 0, testRuns)
summaries, _, err := Search("This is the email body", "", 0, 0, testRuns)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -109,7 +109,7 @@ func TestSearchDelete100(t *testing.T) {
}
}
_, total, err := Search("from:sender@example.com", "", 0, 100)
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -122,7 +122,7 @@ func TestSearchDelete100(t *testing.T) {
t.Fail()
}
_, total, err = Search("from:sender@example.com", "", 0, 100)
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -143,7 +143,7 @@ func TestSearchDelete1100(t *testing.T) {
}
}
_, total, err := Search("from:sender@example.com", "", 0, 100)
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -156,7 +156,7 @@ func TestSearchDelete1100(t *testing.T) {
t.Fail()
}
_, total, err = Search("from:sender@example.com", "", 0, 100)
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
if err != nil {
t.Log("error ", err)
t.Fail()

View File

@@ -13,6 +13,7 @@ import (
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools"
"github.com/axllent/mailpit/server/websockets"
"github.com/leporo/sqlf"
)
@@ -40,7 +41,7 @@ func SetMessageTags(id string, tags []string) ([]string, error) {
continue
}
name, err := AddMessageTag(id, t)
name, err := addMessageTag(id, t)
if err != nil {
return []string{}, err
}
@@ -53,18 +54,25 @@ func SetMessageTags(id string, tags []string) ([]string, error) {
for _, t := range currentTags {
if !tools.InArray(t, applyTags) {
if err := DeleteMessageTag(id, t); err != nil {
if err := deleteMessageTag(id, t); err != nil {
return []string{}, err
}
}
}
}
d := struct {
ID string
Tags []string
}{ID: id, Tags: applyTags}
websockets.Broadcast("update", d)
return tagNames, nil
}
// AddMessageTag adds a tag to a message
func AddMessageTag(id, name string) (string, error) {
func addMessageTag(id, name string) (string, error) {
// prevent two identical tags being added at the same time
addTagMutex.Lock()
@@ -114,11 +122,11 @@ func AddMessageTag(id, name string) (string, error) {
addTagMutex.Unlock()
// add tag to the message
return AddMessageTag(id, name)
return addMessageTag(id, name)
}
// DeleteMessageTag deleted a tag from a message
func DeleteMessageTag(id, name string) error {
// DeleteMessageTag deletes a tag from a message
func deleteMessageTag(id, name string) error {
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
Where(tenant("message_tags.ID")+" = ?", id).
Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN tags ON `+tenant("TagID")+"="+tenant("tags.ID")+` WHERE Name = ?)`, name).
@@ -173,7 +181,6 @@ func GetAllTagsCount() map[string]int64 {
OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags[name] = total
// tags = append(tags, name)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}

View File

@@ -66,7 +66,7 @@ func TestTags(t *testing.T) {
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
// remove first tag
if err := DeleteMessageTag(id, newTags[0]); err != nil {
if err := deleteMessageTag(id, newTags[0]); err != nil {
t.Log("error ", err)
t.Fail()
}

182
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"dayjs": "^1.11.10",
"dompurify": "^3.1.6",
"ical.js": "^2.0.1",
"mitt": "^3.0.1",
"modern-screenshot": "^4.4.30",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",
@@ -42,10 +43,29 @@
"swagger-client": "^3.18.5"
}
},
"node_modules/@babel/parser": {
"node_modules/@babel/helper-string-parser": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz",
"integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.25.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz",
"integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==",
"dependencies": {
"@babel/types": "^7.25.2"
},
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -65,6 +85,19 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz",
"integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==",
"dependencies": {
"@babel/helper-string-parser": "^7.24.8",
"@babel/helper-validator-identifier": "^7.24.7",
"to-fast-properties": "^2.0.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz",
@@ -955,49 +988,49 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"node_modules/@vue/compiler-core": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.31.tgz",
"integrity": "sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.35.tgz",
"integrity": "sha512-gKp0zGoLnMYtw4uS/SJRRO7rsVggLjvot3mcctlMXunYNsX+aRJDqqw/lV5/gHK91nvaAAlWFgdVl020AW1Prg==",
"dependencies": {
"@babel/parser": "^7.24.7",
"@vue/shared": "3.4.31",
"@vue/shared": "3.4.35",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.31.tgz",
"integrity": "sha512-wK424WMXsG1IGMyDGyLqB+TbmEBFM78hIsOJ9QwUVLGrcSk0ak6zYty7Pj8ftm7nEtdU/DGQxAXp0/lM/2cEpQ==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.35.tgz",
"integrity": "sha512-pWIZRL76/oE/VMhdv/ovZfmuooEni6JPG1BFe7oLk5DZRo/ImydXijoZl/4kh2406boRQ7lxTYzbZEEXEhj9NQ==",
"dependencies": {
"@vue/compiler-core": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/compiler-core": "3.4.35",
"@vue/shared": "3.4.35"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.31.tgz",
"integrity": "sha512-einJxqEw8IIJxzmnxmJBuK2usI+lJonl53foq+9etB2HAzlPjAS/wa7r0uUpXw5ByX3/0uswVSrjNb17vJm1kQ==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.35.tgz",
"integrity": "sha512-xacnRS/h/FCsjsMfxBkzjoNxyxEyKyZfBch/P4vkLRvYJwe5ChXmZZrj8Dsed/752H2Q3JE8kYu9Uyha9J6PgA==",
"dependencies": {
"@babel/parser": "^7.24.7",
"@vue/compiler-core": "3.4.31",
"@vue/compiler-dom": "3.4.31",
"@vue/compiler-ssr": "3.4.31",
"@vue/shared": "3.4.31",
"@vue/compiler-core": "3.4.35",
"@vue/compiler-dom": "3.4.35",
"@vue/compiler-ssr": "3.4.35",
"@vue/shared": "3.4.35",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.10",
"postcss": "^8.4.38",
"postcss": "^8.4.40",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.31.tgz",
"integrity": "sha512-RtefmITAje3fJ8FSg1gwgDhdKhZVntIVbwupdyZDSifZTRMiWxWehAOTCc8/KZDnBOcYQ4/9VWxsTbd3wT0hAA==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.35.tgz",
"integrity": "sha512-7iynB+0KB1AAJKk/biENTV5cRGHRdbdaD7Mx3nWcm1W8bVD6QmnH3B4AHhQQ1qZHhqFwzEzMwiytXm3PX1e60A==",
"dependencies": {
"@vue/compiler-dom": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/compiler-dom": "3.4.35",
"@vue/shared": "3.4.35"
}
},
"node_modules/@vue/devtools-api": {
@@ -1006,49 +1039,49 @@
"integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw=="
},
"node_modules/@vue/reactivity": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.31.tgz",
"integrity": "sha512-VGkTani8SOoVkZNds1PfJ/T1SlAIOf8E58PGAhIOUDYPC4GAmFA2u/E14TDAFcf3vVDKunc4QqCe/SHr8xC65Q==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.35.tgz",
"integrity": "sha512-Ggtz7ZZHakriKioveJtPlStYardwQH6VCs9V13/4qjHSQb/teE30LVJNrbBVs4+aoYGtTQKJbTe4CWGxVZrvEw==",
"dependencies": {
"@vue/shared": "3.4.31"
"@vue/shared": "3.4.35"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.31.tgz",
"integrity": "sha512-LDkztxeUPazxG/p8c5JDDKPfkCDBkkiNLVNf7XZIUnJ+66GVGkP+TIh34+8LtPisZ+HMWl2zqhIw0xN5MwU1cw==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.35.tgz",
"integrity": "sha512-D+BAjFoWwT5wtITpSxwqfWZiBClhBbR+bm0VQlWYFOadUUXFo+5wbe9ErXhLvwguPiLZdEF13QAWi2vP3ZD5tA==",
"dependencies": {
"@vue/reactivity": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/reactivity": "3.4.35",
"@vue/shared": "3.4.35"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.31.tgz",
"integrity": "sha512-2Auws3mB7+lHhTFCg8E9ZWopA6Q6L455EcU7bzcQ4x6Dn4cCPuqj6S2oBZgN2a8vJRS/LSYYxwFFq2Hlx3Fsaw==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.35.tgz",
"integrity": "sha512-yGOlbos+MVhlS5NWBF2HDNgblG8e2MY3+GigHEyR/dREAluvI5tuUUgie3/9XeqhPE4LF0i2wjlduh5thnfOqw==",
"dependencies": {
"@vue/reactivity": "3.4.31",
"@vue/runtime-core": "3.4.31",
"@vue/shared": "3.4.31",
"@vue/reactivity": "3.4.35",
"@vue/runtime-core": "3.4.35",
"@vue/shared": "3.4.35",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.31.tgz",
"integrity": "sha512-D5BLbdvrlR9PE3by9GaUp1gQXlCNadIZytMIb8H2h3FMWJd4oUfkUTEH2wAr3qxoRz25uxbTcbqd3WKlm9EHQA==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.35.tgz",
"integrity": "sha512-iZ0e/u9mRE4T8tNhlo0tbA+gzVkgv8r5BX6s1kRbOZqfpq14qoIvCZ5gIgraOmYkMYrSEZgkkojFPr+Nyq/Mnw==",
"dependencies": {
"@vue/compiler-ssr": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/compiler-ssr": "3.4.35",
"@vue/shared": "3.4.35"
},
"peerDependencies": {
"vue": "3.4.31"
"vue": "3.4.35"
}
},
"node_modules/@vue/shared": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.31.tgz",
"integrity": "sha512-Yp3wtJk//8cO4NItOPpi3QkLExAr/aLBGZMmTtW9WpdwBCJpRM6zj9WgWktXAl8IDIozwNMByT45JP3tO3ACWA=="
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.35.tgz",
"integrity": "sha512-hvuhBYYDe+b1G8KHxsQ0diDqDMA8D9laxWZhNAjE83VZb5UDaXl9Xnz7cGdDSyiHM90qqI/CyGMcpBpiDy6VVQ=="
},
"node_modules/anymatch": {
"version": "3.1.3",
@@ -1349,9 +1382,9 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/dayjs": {
"version": "1.11.11",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
"integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg=="
"version": "1.11.12",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz",
"integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg=="
},
"node_modules/decompress-response": {
"version": "6.0.0",
@@ -1954,6 +1987,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -2120,9 +2158,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"version": "8.4.40",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
"integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
"funding": [
{
"type": "opencollective",
@@ -2139,7 +2177,7 @@
],
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
},
"engines": {
@@ -2957,6 +2995,14 @@
"resolved": "https://registry.npmjs.org/timezones-list/-/timezones-list-3.0.3.tgz",
"integrity": "sha512-C+Vdvvj2c1xB6pu81pOX8geo6mrk/QsudFVlTVQET7QQwu8WAIyhDNeCrK5grU7EMzmbKLWqz7uU6dN8fvQvPQ=="
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3067,15 +3113,15 @@
"peer": true
},
"node_modules/vue": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.31.tgz",
"integrity": "sha512-njqRrOy7W3YLAlVqSKpBebtZpDVg21FPoaq1I7f/+qqBThK9ChAIjkRWgeP6Eat+8C+iia4P3OYqpATP21BCoQ==",
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.35.tgz",
"integrity": "sha512-+fl/GLmI4GPileHftVlCdB7fUL4aziPcqTudpTGXCT8s+iZWuOCeNEB5haX6Uz2IpRrbEXOgIFbe+XciCuGbNQ==",
"dependencies": {
"@vue/compiler-dom": "3.4.31",
"@vue/compiler-sfc": "3.4.31",
"@vue/runtime-dom": "3.4.31",
"@vue/server-renderer": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/compiler-dom": "3.4.35",
"@vue/compiler-sfc": "3.4.35",
"@vue/runtime-dom": "3.4.35",
"@vue/server-renderer": "3.4.35",
"@vue/shared": "3.4.35"
},
"peerDependencies": {
"typescript": "*"
@@ -3095,11 +3141,11 @@
}
},
"node_modules/vue-router": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.0.tgz",
"integrity": "sha512-HB+t2p611aIZraV2aPSRNXf0Z/oLZFrlygJm+sZbdJaW6lcFqEDQwnzUBXn+DApw+/QzDU/I9TeWx9izEjTmsA==",
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.2.tgz",
"integrity": "sha512-1qNybkn2L7QsLzaXs8nvlQmRKp8XF8DCxZys/Jr1JpQcHsKUxTKzTxCVA1G7NfBfwRIBgCJPoujOG5lHCCNUxw==",
"dependencies": {
"@vue/devtools-api": "^6.5.1"
"@vue/devtools-api": "^6.6.3"
},
"funding": {
"url": "https://github.com/sponsors/posva"

View File

@@ -17,6 +17,7 @@
"dayjs": "^1.11.10",
"dompurify": "^3.1.6",
"ical.js": "^2.0.1",
"mitt": "^3.0.1",
"modern-screenshot": "^4.4.30",
"prismjs": "^1.29.0",
"rapidoc": "^9.3.4",

View File

@@ -10,12 +10,15 @@ import (
"strconv"
"strings"
"github.com/araddon/dateparse"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/htmlcheck"
"github.com/axllent/mailpit/internal/linkcheck"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/storage"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
)
// GetMessages returns a paginated list of messages as JSON
@@ -48,9 +51,9 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
// Responses:
// 200: MessagesSummaryResponse
// default: ErrorResponse
start, limit := getStartLimit(r)
start, beforeTS, limit := getStartLimit(r)
messages, err := storage.List(start, limit)
messages, err := storage.List(start, beforeTS, limit)
if err != nil {
httpError(w, err.Error())
return
@@ -120,9 +123,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
return
}
start, limit := getStartLimit(r)
start, beforeTS, limit := getStartLimit(r)
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit)
if err != nil {
httpError(w, err.Error())
return
@@ -442,7 +445,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Add("Content-Type", "application/plain")
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
@@ -548,12 +551,20 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
}
}
msg, err := storage.GetMessage(id)
raw, err := storage.GetMessageRaw(id)
if err != nil {
fourOFour(w)
return
}
e := bytes.NewReader(raw)
msg, err := enmime.ReadEnvelope(e)
if err != nil {
httpError(w, err.Error())
return
}
if msg.HTML == "" {
httpError(w, "message does not contain HTML")
return
@@ -704,9 +715,10 @@ func httpJSONError(w http.ResponseWriter, msg string) {
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {
start = 0
limit = 50
beforeTS = 0 // timestamp
s := req.URL.Query().Get("start")
if n, err := strconv.Atoi(s); err == nil && n > 0 {
@@ -718,7 +730,17 @@ func getStartLimit(req *http.Request) (start int, limit int) {
limit = n
}
return start, limit
b := req.URL.Query().Get("before")
if b != "" {
t, err := dateparse.ParseLocal(b)
if err != nil {
logger.Log().Warnf("ignoring invalid before: date \"%s\"", b)
} else {
beforeTS = t.UnixMilli()
}
}
return start, beforeTS, limit
}
// GetOptions returns a blank response

View File

@@ -19,13 +19,13 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" {
messages, _, err = storage.Search(search, "", 0, 1)
messages, _, err = storage.Search(search, "", 0, 0, 1)
if err != nil {
httpError(w, err.Error())
return
}
} else {
messages, err = storage.List(0, 1)
messages, err = storage.List(0, 0, 1)
if err != nil {
httpError(w, err.Error())
return

View File

@@ -26,9 +26,10 @@ func sendData(c net.Conn, m string) {
fmt.Fprintf(c, "%s\r\n", m)
}
// Get the latest 100 messages
func getMessages() ([]message, error) {
messages := []message{}
list, err := storage.List(0, 100)
list, err := storage.List(0, 0, 100)
if err != nil {
return messages, err
}

View File

@@ -1,11 +1,18 @@
import App from './App.vue'
import router from './router'
import { createApp } from 'vue'
import mitt from 'mitt';
import './assets/styles.scss'
import 'bootstrap-icons/font/bootstrap-icons.scss'
import 'bootstrap'
const app = createApp(App)
// Global event bus used to subscribe to websocket events
// such as message deletes, updates & truncation.
const eventBus = mitt()
app.provide('eventBus', eventBus)
app.use(router)
app.mount('#app')

View File

@@ -91,44 +91,6 @@
}
}
.about-mailpit {
@include media-breakpoint-down(md) {
width: var(--bs-offcanvas-width);
margin-left: -1rem !important;
}
}
.message {
.subject {
color: $text-muted;
b {
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.read {
color: $text-muted;
b {
opacity: 0.7;
font-weight: normal;
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
}
.text-spaces-nowrap {
white-space: pre;
}
@@ -266,8 +228,35 @@
}
}
.list-group-item.message:first-child {
border-top: 0;
#message-page {
.list-group-item.message:first-child {
border-top: 0;
}
.message {
.subject {
color: $text-muted;
b {
color: $list-group-color;
}
small {
opacity: 0.5;
}
}
&.read {
color: $text-muted;
b {
color: $list-group-color;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
}
}
body.blur {
@@ -320,6 +309,18 @@ body.blur {
display: none;
}
.message {
&.read {
> div {
opacity: 0.7;
}
b {
font-weight: normal;
}
}
}
#message-view {
.form-control.dropdown {
padding: 0;

View File

@@ -54,14 +54,13 @@ export default {
<template>
<template v-if="!modals">
<div
class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
<button class="text-muted btn btn-sm ps-0" v-on:click="loadInfo()">
<i class="bi bi-info-circle-fill me-1"></i>
About
</button>
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
data-bs-target="#SettingsModal" title="Mailpit UI settings">
<i class="bi bi-gear-fill"></i>
</button>
@@ -152,7 +151,7 @@ export default {
<div class="card-header h4">
Runtime statistics
<button class="btn btn-sm btn-outline-secondary float-end"
v-on:click="loadInfo">
v-on:click="loadInfo()">
Refresh
</button>
</div>
@@ -183,8 +182,8 @@ export default {
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
<small class="text-secondary">
({{
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
}})
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
}})
</small>
</td>
</tr>

View File

@@ -20,9 +20,12 @@ export default {
}
},
mounted() {
created() {
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
},
mounted() {
this.refreshUI()
},
@@ -139,7 +142,7 @@ export default {
</b>
</div>
<div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }}
To: {{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{ message.To.length - 1 }}]
</span>

View File

@@ -6,8 +6,6 @@ import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
emits: ['loadMessages'],
data() {
return {
mailbox,
@@ -24,7 +22,7 @@ export default {
return false
}
let re = new RegExp(`(^|\\s)tag:"?${tag}"?($|\\s)`, 'i')
let re = new RegExp(`\\btag:("${tag}"|${tag}\\b)`, 'i')
return query.match(re)
},
@@ -100,7 +98,7 @@ export default {
</li>
</ul>
</div>
<div class="list-group mt-1 mb-5 pb-3">
<div class="list-group mt-1 mb-2">
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click="hideNav"
v-on:click="pagination.start = 0" v-on:click.ctrl="toggleTag($event, tag)"
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"

View File

@@ -7,6 +7,9 @@ import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
data() {
return {
pagination,
@@ -63,7 +66,7 @@ export default {
} else {
// update pagination offset
pagination.start++
// prevent "Too many calls to Location or History APIs within a short timeframe"
// prevent "Too many calls to Location or History APIs within a short time frame"
this.delayedPaginationUpdate()
}
}
@@ -91,6 +94,7 @@ export default {
window.scrollInPlace = true
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
this.eventBus.emit("prune");
} else if (response.Type == "stats" && response.Data) {
// refresh mailbox stats
mailbox.total = response.Data.Total
@@ -100,6 +104,15 @@ export default {
if (this.version != response.Data.Version) {
location.reload()
}
} else if (response.Type == "delete" && response.Data) {
// broadcast for components
this.eventBus.emit("delete", response.Data);
} else if (response.Type == "update" && response.Data) {
// broadcast for components
this.eventBus.emit("update", response.Data);
} else if (response.Type == "truncate") {
// broadcast for components
this.eventBus.emit("truncate")
}
}

View File

@@ -185,17 +185,19 @@ export default {
// delay 0.2s until vue has rendered the iframe content
window.setTimeout(() => {
let p = document.getElementById('preview-html')
if (p && typeof p.contentWindow.document.body != 'undefined') {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i]
let href = anchorEl.getAttribute('href')
if (p && typeof p.contentWindow.document.body == 'object') {
try {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i]
let href = anchorEl.getAttribute('href')
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank')
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank')
}
}
}
} catch (error) { }
this.resizeIFrames()
}
}, 200)
@@ -208,7 +210,9 @@ export default {
resizeIframe(el) {
let i = el.target
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
if (typeof i.contentWindow.document.body.scrollHeight == 'number') {
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
}
},
resizeIFrames() {
@@ -217,7 +221,9 @@ export default {
}
let h = document.getElementById('preview-html')
if (h) {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
if (typeof h.contentWindow.document.body.scrollHeight == 'number') {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
}
}
},
@@ -306,7 +312,7 @@ export default {
</script>
<template>
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100" style="overflow-y: scroll;">
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
<div class="row w-100">
<div class="col-md">
<table class="messageHeaders">
@@ -403,11 +409,13 @@ export default {
<small class="text-body-secondary" v-else>[ no subject ]</small>
</td>
</tr>
<tr class="d-md-none small">
<tr class="small">
<th class="small">Date</th>
<td>{{ messageDate(message.Date) }}</td>
<td>
{{ messageDate(message.Date) }}
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
</td>
</tr>
<tr class="small">
<th>Tags</th>
<td>

View File

@@ -115,8 +115,10 @@ export default {
* @params function callback function
* @params function error callback function
*/
get(url, values, callback, errorCallback) {
this.loading++
get(url, values, callback, errorCallback, hideLoader) {
if (!hideLoader) {
this.loading++
}
axios.get(url, { params: values })
.then(callback)
.catch((err) => {
@@ -128,7 +130,7 @@ export default {
})
.then(() => {
// always executed
if (this.loading > 0) {
if (!hideLoader && this.loading > 0) {
this.loading--
}
})
@@ -273,6 +275,11 @@ export default {
return 'bi-file-arrow-down-fill'
},
// wrapper to update one or more messages with
updateMessages(messages, updates) {
},
// Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing.
colorHash(s) {

View File

@@ -14,6 +14,9 @@ import { pagination } from "../stores/pagination";
export default {
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: {
AboutMailpit,
AjaxLoader,
@@ -27,6 +30,7 @@ export default {
data() {
return {
mailbox,
delayedRefresh: false,
}
},
@@ -40,6 +44,18 @@ export default {
mailbox.searching = false
this.apiURI = this.resolve(`/api/v1/messages`)
this.loadMailbox()
// subscribe to events
this.eventBus.on("update", this.handleWSUpdate)
this.eventBus.on("delete", this.handleWSDelete)
this.eventBus.on("truncate", this.handleWSTruncate)
},
unmounted() {
// unsubscribe from events
this.eventBus.off("update", this.handleWSUpdate)
this.eventBus.off("delete", this.handleWSDelete)
this.eventBus.off("truncate", this.handleWSTruncate)
},
methods: {
@@ -55,7 +71,51 @@ export default {
}
this.loadMessages()
}
},
// handler for websocket message updates
handleWSUpdate(data) {
for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) {
// update message
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
return
}
}
},
// handler for websocket message deletion
handleWSDelete(data) {
let removed = 0;
for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) {
// remove message from the list
this.mailbox.messages.splice(x, 1)
removed++
continue
}
}
if (!removed || this.delayedRefresh) {
// nothing changed on this screen, or a refresh is queued,
// don't refresh
return
}
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
this.delayedRefresh = true
window.setTimeout(() => {
this.delayedRefresh = false
this.loadMessages()
}, 500)
},
// handler for websocket message truncation
handleWSTruncate() {
// all messages gone, reload
this.loadMessages()
},
}
}
</script>
@@ -89,18 +149,24 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<NavMailbox @loadMessages="loadMessages" />
<NavTags />
<AboutMailpit />
<div class="offcanvas-body pb-0">
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto">
<NavMailbox @loadMessages="loadMessages" />
<NavTags />
</div>
<AboutMailpit />
</div>
</div>
</div>
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
style="overflow-y: auto; overflow-x: hidden;">
<NavMailbox @loadMessages="loadMessages" />
<NavTags />
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
<div class="flex-grow-1 overflow-y-auto">
<NavMailbox @loadMessages="loadMessages" />
<NavTags />
</div>
<AboutMailpit />
</div>

View File

@@ -7,10 +7,14 @@ import Release from '../components/message/Release.vue'
import Screenshot from '../components/message/Screenshot.vue'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
import dayjs from 'dayjs'
export default {
mixins: [CommonMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: {
AboutMailpit,
AjaxLoader,
@@ -24,20 +28,105 @@ export default {
mailbox,
pagination,
message: false,
prevLink: false,
nextLink: false,
errorMessage: false,
apiSideNavURI: false,
apiSideNavParams: URLSearchParams,
apiIsMore: true,
messagesList: [],
scrollLoading: false,
canLoadMore: true,
}
},
watch: {
$route(to, from) {
this.loadMessage()
}
},
},
created() {
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
this.initLoadMoreAPIParams()
},
mounted() {
this.loadMessage()
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages))
if (!this.messagesList.length) {
this.loadMore()
}
this.refreshUI()
// subscribe to events
this.eventBus.on("update", this.handleWSUpdate)
this.eventBus.on("delete", this.handleWSDelete)
this.eventBus.on("truncate", this.handleWSTruncate)
},
unmounted() {
// unsubscribe from events
this.eventBus.off("update", this.handleWSUpdate)
this.eventBus.off("delete", this.handleWSDelete)
this.eventBus.off("truncate", this.handleWSTruncate)
},
computed: {
// get current message read status
isRead() {
const l = this.messagesList.length
if (!this.message || !l) {
return true
}
let id = false
for (x = 0; x < l; x++) {
if (this.messagesList[x].ID == this.message.ID) {
return this.messagesList[x].Read
}
}
return true
},
// get the previous message ID
previousID() {
const l = this.messagesList.length
if (!this.message || !l) {
return false
}
let id = false
for (x = 0; x < l; x++) {
if (this.messagesList[x].ID == this.message.ID) {
return id
}
id = this.messagesList[x].ID
}
return false
},
// get the next message ID
nextID() {
const l = this.messagesList.length
if (!this.message || !l) {
return false
}
let id = false
for (x = l - 1; x > 0; x--) {
if (this.messagesList[x].ID == this.message.ID) {
return id
}
id = this.messagesList[x].ID
}
return id
}
},
methods: {
@@ -48,9 +137,8 @@ export default {
this.errorMessage = false
const d = response.data
if (this.wasUnread(d.ID)) {
mailbox.unread--
}
// update read status in case websockets is not working
this.handleWSUpdate({ 'ID': d.ID, Read: true })
// replace inline images embedded as inline attachments
if (d.HTML && d.Inline) {
@@ -94,7 +182,9 @@ export default {
this.message = d
this.detectPrevNext()
this.$nextTick(() => {
this.scrollSidebarToCurrent()
})
},
(error) => {
this.errorMessage = true
@@ -114,37 +204,145 @@ export default {
})
},
// try detect whether this message was unread based on messages listing
wasUnread(id) {
for (let m in mailbox.messages) {
if (mailbox.messages[m].ID == id) {
if (!mailbox.messages[m].Read) {
mailbox.messages[m].Read = true
return true
}
return false
// UI refresh ticker to adjust relative times
refreshUI() {
window.setTimeout(() => {
this.$forceUpdate()
this.refreshUI()
}, 30000)
},
// handler for websocket message updates
handleWSUpdate(data) {
for (let x = 0; x < this.messagesList.length; x++) {
if (this.messagesList[x].ID == data.ID) {
// update message
this.messagesList[x] = { ...this.messagesList[x], ...data }
return
}
}
},
detectPrevNext() {
// generate the prev/next links based on current message list
this.prevLink = false
this.nextLink = false
let found = false
for (let m in mailbox.messages) {
if (mailbox.messages[m].ID == this.message.ID) {
found = true
} else if (found && !this.nextLink) {
this.nextLink = mailbox.messages[m].ID
break
} else {
this.prevLink = mailbox.messages[m].ID
// handler for websocket message deletion
handleWSDelete(data) {
for (let x = 0; x < this.messagesList.length; x++) {
if (this.messagesList[x].ID == data.ID) {
// remove message from the list
this.messagesList.splice(x, 1)
return
}
}
},
// handler for websocket message truncation
handleWSTruncate() {
// all messages gone, go to inbox
this.$router.push('/')
},
// return whether the sidebar is visible
sidebarVisible() {
return this.$refs.MessageList.offsetParent != null
},
// scroll sidenav to current message if found
scrollSidebarToCurrent() {
const cont = document.getElementById('MessageList')
if (!cont) {
return
}
const c = cont.querySelector('.router-link-active')
if (c) {
const outer = cont.getBoundingClientRect()
const li = c.getBoundingClientRect()
if (outer.top > li.top || outer.bottom < li.bottom) {
c.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
}
}
},
scrollHandler(e) {
if (!this.canLoadMore || this.scrollLoading) {
return
}
const { scrollTop, offsetHeight, scrollHeight } = e.target
if ((scrollTop + offsetHeight + 150) >= scrollHeight) {
this.loadMore()
}
},
loadMore() {
if (this.messagesList.length) {
// get last created timestamp
const oldest = this.messagesList[this.messagesList.length - 1].Created
// if set append `before=<ts>`
this.apiSideNavParams.set('before', oldest)
}
this.scrollLoading = true
this.get(this.apiSideNavURI, this.apiSideNavParams, (response) => {
if (response.data.messages.length) {
this.messagesList.push(...response.data.messages)
} else {
this.canLoadMore = false
}
this.$nextTick(() => {
this.scrollLoading = false
})
}, null, true)
},
initLoadMoreAPIParams() {
let apiURI = this.resolve(`/api/v1/messages`)
let p = {}
if (mailbox.searching) {
apiURI = this.resolve(`/api/v1/search`)
p.query = mailbox.searching
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
this.apiSideNavURI = apiURI
this.apiSideNavParams = new URLSearchParams(p)
},
getRelativeCreated(message) {
const d = new Date(message.Created)
return dayjs(d).fromNow()
},
getPrimaryEmailTo(message) {
for (let i in message.To) {
return message.To[i].Address
}
return '[ Undisclosed recipients ]'
},
isActive(id) {
return this.message.ID == id
},
toTagUrl(t) {
if (t.match(/ /)) {
t = `"${t}"`
}
const p = {
q: 'tag:' + t
}
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
return '/search?' + params.toString()
},
downloadMessageBody(str, ext) {
const dl = document.createElement('a')
dl.href = "data:text/plain," + encodeURIComponent(str)
@@ -157,25 +355,44 @@ export default {
this.$refs.ScreenshotRef.initScreenshot()
},
// mark current message as read
markUnread() {
// toggle current message read status
toggleRead() {
if (!this.message) {
return false
}
const read = !this.isRead
const ids = [this.message.ID]
const uri = this.resolve('/api/v1/messages')
this.put(uri, { 'read': false, 'ids': [this.message.ID] }, (response) => {
this.goBack()
this.put(uri, { 'Read': read, 'IDs': ids }, () => {
if (!this.sidebarVisible()) {
return this.goBack()
}
// manually update read status in case websockets is not working
this.handleWSUpdate({ 'ID': this.message.ID, Read: read })
})
},
deleteMessage() {
const ids = [this.message.ID]
const uri = this.resolve('/api/v1/messages')
this.delete(uri, { 'ids': ids }, () => {
this.goBack()
// calculate next ID before deletion to prevent WS race
const goToID = this.nextID ? this.nextID : this.previousID
this.delete(uri, { 'IDs': ids }, () => {
if (!this.sidebarVisible()) {
return this.goBack()
}
if (goToID) {
return this.$router.push('/view/' + goToID)
}
return this.goBack()
})
},
// return to mailbox or search based on origin
goBack() {
mailbox.lastMessage = this.$route.params.id
@@ -189,8 +406,7 @@ export default {
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
this.$router.push('/search?' + params.toString())
this.$router.push('/search?' + new URLSearchParams(p).toString())
} else {
const p = {}
if (pagination.start > 0) {
@@ -199,8 +415,7 @@ export default {
if (pagination.limit != pagination.defaultLimit) {
p.limit = pagination.limit.toString()
}
const params = new URLSearchParams(p)
this.$router.push('/?' + params.toString())
this.$router.push('/?' + new URLSearchParams(p).toString())
}
},
@@ -218,25 +433,27 @@ export default {
<template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
<div class="d-none d-md-block col-xl-2 col-md-3 col-auto pe-0">
<div class="d-none d-xl-block col-xl-3 col-auto pe-0">
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
</RouterLink>
</div>
<div class="col col-md-4k col-lg-5 col-xl-6" v-if="!errorMessage">
<button @click="goBack()" class="btn btn-outline-light me-3 me-sm-4 d-md-none" title="Return to messages">
<div class="col col-xl-5" v-if="!errorMessage">
<button @click="goBack()" class="btn btn-outline-light me-3 d-xl-none" title="Return to messages">
<i class="bi bi-arrow-return-left"></i>
<span class="ms-2 d-none d-lg-inline">Back</span>
</button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="markUnread">
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()">
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
</button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
v-on:click="initReleaseModal">
v-on:click="initReleaseModal()">
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
</button>
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage">
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()">
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
</button>
</div>
@@ -297,19 +514,18 @@ export default {
</ul>
</div>
<RouterLink :to="'/view/' + prevLink" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
:class="prevLink ? '' : 'disabled'" title="View previous message">
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
:class="previousID ? '' : 'disabled'" title="View previous message">
<i class="bi bi-caret-left-fill"></i>
</RouterLink>
<RouterLink :to="'/view/' + nextLink" class="btn btn-outline-light" :class="nextLink ? '' : 'disabled'">
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
<i class="bi bi-caret-right-fill" title="View next message"></i>
</RouterLink>
</div>
</div>
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
style="overflow-y: auto; overflow-x: hidden;">
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
@@ -318,7 +534,11 @@ export default {
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="goBack()" class="list-group-item list-group-item-action">
<i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">Return</span>
<span class="ms-1">
Return to
<template v-if="mailbox.searching">search</template>
<template v-else>mailbox</template>
</span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
v-if="mailbox.unread && !errorMessage">
{{ formatNumber(mailbox.unread) }}
@@ -326,24 +546,45 @@ export default {
</button>
</div>
<div class="card mt-4" v-if="!errorMessage">
<div class="card-body text-body-secondary small">
<p class="card-text">
<b>Message date:</b><br>
<small>{{ messageDate(message.Date) }}</small>
</p>
<p class="card-text">
<b>Size:</b> {{ getFileSize(message.Size) }}
</p>
<p class="card-text" v-if="allAttachments(message).length">
<b>Attachments:</b> {{ allAttachments(message).length }}
</p>
</div>
<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList"
@scroll="scrollHandler">
<template v-if="messagesList && messagesList.length">
<div class="list-group">
<RouterLink v-for="message in messagesList" :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"
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
<div class="col-12 overflow-x-hidden">
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
</div>
<div class="col overflow-x-hidden">
<div class="text-truncate privacy small">
To: {{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{ message.To.length - 1 }}]
</span>
</div>
</div>
<div class="col-auto small">
{{ getRelativeCreated(message) }}
</div>
<div v-if="message.Tags.length" class="col-12">
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
v-on:click="pagination.start = 0"
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
:title="'Filter messages tagged with ' + t">
{{ t }}
</RouterLink>
</div>
</RouterLink>
</div>
</template>
</div>
<AboutMailpit />
</div>
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page">
<template v-if="errorMessage">
<h3 class="text-center my-3">

View File

@@ -14,6 +14,9 @@ import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins, MessagesMixins],
// global event bus to handle message status changes
inject: ["eventBus"],
components: {
AboutMailpit,
AjaxLoader,
@@ -28,6 +31,7 @@ export default {
return {
mailbox,
pagination,
delayedRefresh: false,
}
},
@@ -40,6 +44,18 @@ export default {
mounted() {
mailbox.searching = this.getSearch()
this.doSearch()
// subscribe to events
this.eventBus.on("update", this.handleWSUpdate)
this.eventBus.on("delete", this.handleWSDelete)
this.eventBus.on("truncate", this.handleWSTruncate)
},
unmounted() {
// unsubscribe from events
this.eventBus.off("update", this.handleWSUpdate)
this.eventBus.off("delete", this.handleWSDelete)
this.eventBus.off("truncate", this.handleWSTruncate)
},
methods: {
@@ -59,7 +75,50 @@ export default {
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
}
this.loadMessages()
}
},
// handler for websocket message updates
handleWSUpdate(data) {
for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) {
// update message
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
return
}
}
},
// handler for websocket message deletion
handleWSDelete(data) {
let removed = 0;
for (let x = 0; x < this.mailbox.messages.length; x++) {
if (this.mailbox.messages[x].ID == data.ID) {
// remove message from the list
this.mailbox.messages.splice(x, 1)
removed++
continue
}
}
if (!removed || this.delayedRefresh) {
// nothing changed on this screen, or a refresh is queued, don't refresh
return
}
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
this.delayedRefresh = true
window.setTimeout(() => {
this.delayedRefresh = false
this.loadMessages()
}, 500)
},
// handler for websocket message truncation
handleWSTruncate() {
// all messages deleted, go back to inbox
this.$router.push('/')
},
}
}
</script>
@@ -93,18 +152,23 @@ export default {
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<NavSearch @loadMessages="loadMessages" />
<NavTags @loadMessages="loadMessages" />
<AboutMailpit />
<div class="offcanvas-body pb-0">
<div class="d-flex flex-column h-100">
<div class="flex-grow-1 overflow-y-auto">
<NavSearch @loadMessages="loadMessages" />
<NavTags />
</div>
<AboutMailpit />
</div>
</div>
</div>
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
style="overflow-y: auto; overflow-x: hidden;">
<NavSearch @loadMessages="loadMessages" />
<NavTags @loadMessages="loadMessages" />
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
<div class="flex-grow-1 overflow-y-auto">
<NavSearch @loadMessages="loadMessages" />
<NavTags />
</div>
<AboutMailpit />
</div>

View File

@@ -3,6 +3,7 @@ package websockets
import (
"encoding/json"
"time"
"github.com/axllent/mailpit/internal/logger"
)
@@ -83,5 +84,9 @@ func Broadcast(t string, msg interface{}) {
return
}
// add a very small delay to prevent broadcasts from being interpreted
// as a multi-line messages (eg: storage.DeleteMessages() which can send a very quick series)
time.Sleep(time.Millisecond)
go func() { MessageHub.Broadcast <- b }()
}