1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-03-17 21:18:19 +02:00

Merge branch 'release/v1.9.0'

This commit is contained in:
Ralph Slooten 2023-09-22 16:40:49 +12:00
commit 8c4b1ac445
55 changed files with 3585 additions and 2529 deletions

View File

@ -8,7 +8,7 @@ jobs:
test:
strategy:
matrix:
go-version: [1.18.x]
go-version: [1.21.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:

View File

@ -2,6 +2,32 @@
Notable changes to Mailpit will be documented in this file.
## [v1.9.0]
### API
- Remove redundant `Read` status from message (always true)
- Delete by search filter
- Add endpoint to return all tags in use
### Feature
- Improved search parser
- New search filter `[!]is:tagged`
### Fix
- Correctly escape certain characters in search (eg: `'`)
### Libs
- Update minimum Go version to 1.20
- Update Go modules
- Update node modules
### Tests
- Bump Go version to 1.21
### UI
- Rewrite web UI, add URL routing and components
## [v1.8.4]
### Fix

View File

@ -12,7 +12,6 @@ import (
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/mattn/go-shellwords"
"github.com/tg123/go-htpasswd"
"gopkg.in/yaml.v3"
)
@ -228,13 +227,8 @@ func VerifyConfig() error {
SMTPTags = []AutoTag{}
p := shellwords.NewParser()
if SMTPCLITags != "" {
args, err := p.Parse(SMTPCLITags)
if err != nil {
return fmt.Errorf("Error parsing tags (%s)", err)
}
args := tools.ArgsParser(SMTPCLITags)
for _, a := range args {
t := strings.Split(a, "=")

25
go.mod
View File

@ -1,20 +1,19 @@
module github.com/axllent/mailpit
go 1.18
go 1.20
require (
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/PuerkitoBio/goquery v1.8.1
github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12
github.com/gomarkdown/markdown v0.0.0-20230916125811-7478c230c7cd
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v1.0.0
github.com/jhillyerd/enmime v1.0.1
github.com/k3a/html2text v1.2.1
github.com/klauspost/compress v1.16.7
github.com/klauspost/compress v1.17.0
github.com/leporo/sqlf v1.4.0
github.com/mattn/go-shellwords v1.0.12
github.com/mhale/smtpd v0.8.0
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/satori/go.uuid v1.2.0
@ -23,8 +22,8 @@ require (
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.1
github.com/vanng822/go-premailer v1.20.2
golang.org/x/net v0.14.0
golang.org/x/text v0.12.0
golang.org/x/net v0.15.0
golang.org/x/text v0.13.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.25.0
)
@ -37,7 +36,7 @@ require (
github.com/cznic/ql v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
@ -53,18 +52,18 @@ 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.12.0 // indirect
golang.org/x/image v0.11.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/image v0.12.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/tools v0.12.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/tools v0.13.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.0 // indirect
modernc.org/memory v1.7.1 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect

50
go.sum
View File

@ -47,16 +47,16 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
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/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12 h1:uK3X/2mt4tbSGoHvbLBHUny7CKiuwUip3MArtukol4E=
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20230916125811-7478c230c7cd h1:laCEzrtkKEkT2424vMTGl6N1m0xN8kq371hksD5Be+8=
github.com/gomarkdown/markdown v0.0.0-20230916125811-7478c230c7cd/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
@ -69,16 +69,16 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.0.0 h1:8swYgO1fm68PllCKz5jiLzgD3axNUS388jr6BtRSsl8=
github.com/jhillyerd/enmime v1.0.0/go.mod h1:EktNOa/V6ka9yCrfoB2uxgefp1lno6OVdszW0iQ5LnM=
github.com/jhillyerd/enmime v1.0.1 h1:y6RyqIgBOI2hIinOXIzmeB+ITRVls0zTJIm5GwgXnjE=
github.com/jhillyerd/enmime v1.0.1/go.mod h1:LMMbm6oTlzWHghPavqHtOrP/NosVv3l42CUrZjn03/Q=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@ -93,8 +93,6 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
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-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
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/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
@ -138,8 +136,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/tg123/go-htpasswd v1.2.1 h1:i4wfsX1KvvkyoMiHZzjS0VzbAPWfxzI8INcZAKtutoU=
github.com/tg123/go-htpasswd v1.2.1/go.mod h1:erHp1B86KXdwQf1X5ZrLb7erXZnWueEQezb2dql4q58=
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
@ -154,11 +152,11 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
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.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
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.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
@ -175,8 +173,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.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.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
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=
@ -193,8 +191,8 @@ 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.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
@ -205,15 +203,15 @@ golang.org/x/text v0.3.6/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.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
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=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -235,8 +233,8 @@ modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
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.7.0 h1:2pXdbgdP5hIyDp2JqIwkHNZ1sAjEbh8GnRpcqFWBf7E=
modernc.org/memory v1.7.0/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.7.1 h1:9J+2/GKTlV503mk3yv8QJ6oEpRCUrRy0ad8TXEPoV8M=
modernc.org/memory v1.7.1/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA=

803
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,12 +20,15 @@
"rapidoc": "^9.3.4",
"tinycon": "^0.6.8",
"vue": "^3.2.13",
"vue-css-donut-chart": "^2.0.0"
"vue-css-donut-chart": "^2.0.0",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",
"@types/bootstrap": "^5.2.7",
"@types/tinycon": "^0.6.3",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.18.10",
"esbuild": "^0.19.1",
"esbuild-plugin-vue-next": "^0.1.4",
"esbuild-sass-plugin": "^2.3.2"
}

View File

@ -131,8 +131,8 @@ func Search(w http.ResponseWriter, r *http.Request) {
res.Start = start
res.Messages = messages
res.Count = results // legacy - now undocumented in API specs
res.Total = stats.Total
res.Count = len(messages) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox
res.MessagesCount = results
res.Unread = stats.Unread
res.Tags = stats.Tags
@ -142,6 +142,44 @@ func Search(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes)
}
// DeleteSearch will delete all messages matching a search
func DeleteSearch(w http.ResponseWriter, r *http.Request) {
// swagger:route DELETE /api/v1/search messages MessagesSummary
//
// # Delete messages by search
//
// Deletes messages matching a search.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Parameters:
// + name: query
// in: query
// description: Search query
// required: true
// type: string
//
// Responses:
// 200: OKResponse
// default: ErrorResponse
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
httpError(w, "Error: no search query")
return
}
if err := storage.DeleteSearch(search); err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// GetMessage (method: GET) returns the Message as JSON
func GetMessage(w http.ResponseWriter, r *http.Request) {
// swagger:route GET /api/v1/message/{ID} message Message
@ -368,7 +406,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Add("Content-Type", "text/plain")
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write([]byte("ok"))
}
@ -451,6 +489,35 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}
// GetTags (method: GET) will get all tags currently in use
func GetTags(w http.ResponseWriter, _ *http.Request) {
// swagger:route GET /api/v1/tags tags SetTags
//
// # Get all current tags
//
// Returns a JSON array of all unique message tags.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 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)
}
// SetTags (method: PUT) will set the tags for all provided IDs
func SetTags(w http.ResponseWriter, r *http.Request) {
// swagger:route PUT /api/v1/tags tags SetTags
@ -778,7 +845,7 @@ func getStartLimit(req *http.Request) (start int, limit int) {
}
// GetOptions returns a blank response
func GetOptions(w http.ResponseWriter, r *http.Request) {
func GetOptions(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(""))

View File

@ -96,3 +96,7 @@ type okResponse struct {
// in: body
Body string
}
// Plain JSON array response
// swagger:response ArrayResponse
type arrayResponse []string

View File

@ -12,11 +12,13 @@ import (
"os"
"strings"
"sync/atomic"
"text/template"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/server/apiv1"
"github.com/axllent/mailpit/server/handlers"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/storage"
"github.com/axllent/mailpit/utils/logger"
"github.com/gorilla/mux"
)
@ -42,7 +44,7 @@ func Listen() {
go websockets.MessageHub.Run()
r := defaultRoutes()
r := apiRoutes()
// kubernetes probes
r.HandleFunc(config.Webroot+"livez", handlers.HealthzHandler)
@ -51,18 +53,24 @@ func Listen() {
// proxy handler for screenshots
r.HandleFunc(config.Webroot+"proxy", middleWareFunc(handlers.ProxyHandler)).Methods("GET")
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// virtual filesystem for others
r.PathPrefix(config.Webroot).Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// virtual filesystem for /dist/ & some individual files
r.PathPrefix(config.Webroot + "dist/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.PathPrefix(config.Webroot + "api/").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "favicon.ico").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "favicon.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "mailpit.svg").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
r.Path(config.Webroot + "notification.png").Handler(middlewareHandler(http.StripPrefix(config.Webroot, http.FileServer(http.FS(serverRoot)))))
// redirect to webroot if no trailing slash
if config.Webroot != "/" {
redir := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redir, middleWareFunc(addSlashToWebroot)).Methods("GET")
redirect := strings.TrimRight(config.Webroot, "/")
r.HandleFunc(redirect, middleWareFunc(addSlashToWebroot)).Methods("GET")
}
// handle everything else with the virtual index.html
r.PathPrefix(config.Webroot).Handler(middleWareFunc(index)).Methods("GET")
// put it all together
http.Handle("/", r)
if config.UIAuthFile != "" {
@ -81,15 +89,17 @@ func Listen() {
}
}
func defaultRoutes() *mux.Router {
func apiRoutes() *mux.Router {
r := mux.NewRouter()
// API V1
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.GetTags)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetTags)).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/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")
@ -104,6 +114,9 @@ func defaultRoutes() *mux.Router {
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
r.HandleFunc(config.Webroot+"api/v1/swagger.json", middleWareFunc(swaggerBasePath)).Methods("GET")
// web UI websocket
r.HandleFunc(config.Webroot+"api/events", apiWebsocket).Methods("GET")
// return blank 200 response for OPTIONS requests for CORS
r.PathPrefix(config.Webroot + "api/v1/").Handler(middleWareFunc(apiv1.GetOptions)).Methods("OPTIONS")
@ -211,6 +224,7 @@ func addSlashToWebroot(w http.ResponseWriter, r *http.Request) {
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
storage.BroadcastMailboxStats()
}
// Wrapper to artificially inject a basePath to the swagger.json if a webroot has been specified
@ -230,3 +244,59 @@ func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(f)
}
// Just returns the default HTML template
func index(w http.ResponseWriter, _ *http.Request) {
var h = `<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="{{ .Webroot }}favicon.svg">
<title>Mailpit</title>
<link rel=stylesheet href="{{ .Webroot }}dist/app.css?{{ .Version }}">
</head>
<body class="h-100">
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}">
<noscript>You require JavaScript to use this app.</noscript>
</div>
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
</body>
</html>`
t, err := template.New("index").Parse(h)
if err != nil {
panic(err)
}
data := struct {
Webroot string
Version string
}{
Webroot: config.Webroot,
Version: config.Version,
}
buff := new(bytes.Buffer)
err = t.Execute(buff, data)
if err != nil {
panic(err)
}
buff.Bytes()
// f, err := embeddedFS.ReadFile("public/index.html")
// if err != nil {
// panic(err)
// }
w.Header().Add("Content-Type", "text/html")
_, _ = w.Write(buff.Bytes())
}

View File

@ -4,7 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"net/url"
@ -29,7 +29,7 @@ func Test_APIv1(t *testing.T) {
setup()
defer storage.Close()
r := defaultRoutes()
r := apiRoutes()
ts := httptest.NewServer(r)
defer ts.Close()
@ -57,8 +57,8 @@ func Test_APIv1(t *testing.T) {
// read first 10
t.Log("Read first 10 messages including raw & headers")
putIDS := []string{}
for indx, msg := range m.Messages {
if indx == 10 {
for idx, msg := range m.Messages {
if idx == 10 {
break
}
@ -253,7 +253,7 @@ func clientGet(url string) ([]byte, error) {
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
return data, err
}
@ -278,7 +278,7 @@ func clientDelete(url, body string) ([]byte, error) {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
return data, err
}
@ -303,7 +303,7 @@ func clientPut(url, body string) ([]byte, error) {
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
}
data, err := ioutil.ReadAll(resp.Body)
data, err := io.ReadAll(resp.Body)
return data, err
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,11 @@
import { createApp } from 'vue';
import App from './App.vue';
import "./assets/styles.scss";
import "bootstrap-icons/font/bootstrap-icons.scss";
import "bootstrap";
import App from './App.vue'
import router from './router'
import { createApp } from 'vue'
createApp(App).mount('#app');
import './assets/styles.scss'
import 'bootstrap-icons/font/bootstrap-icons.scss'
import 'bootstrap'
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@ -39,7 +39,7 @@
// @import "bootstrap/scss/popover";
// @import "bootstrap/scss/carousel";
@import "bootstrap/scss/spinners";
// @import "bootstrap/scss/offcanvas";
@import "bootstrap/scss/offcanvas";
// @import "bootstrap/scss/popover";
@import "bootstrap/scss/progress";

View File

@ -1,9 +1,21 @@
// Removed "Noto Color Emoji" from list re: https://github.com/axllent/mailpit/issues/92
$font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans",
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-family-sans-serif:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
"Helvetica Neue",
"Noto Sans",
"Liberation Sans",
Arial,
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol";
$link-decoration: none;
$primary: #2c3e50;
$list-group-disabled-color: #adb5bd;
$enable-negative-margins: true;
$body-color-dark: #e7eaed;
$offcanvas-border-width: 0;

View File

@ -1,373 +1,376 @@
@import "./bootstrap";
[v-cloak] {
display: none !important;
display: none !important;
}
.navbar {
z-index: 99;
z-index: 99;
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
.navbar-brand {
color: #2d4a5d;
transition: all 0.2s;
img {
width: 40px;
}
img {
width: 40px;
}
@include media-breakpoint-down(md) {
padding: 0;
@include media-breakpoint-down(md) {
padding: 0;
img {
width: 35px;
}
}
}
img {
width: 35px;
}
}
}
}
.navbar-brand {
span {
opacity: 0.8;
transition: all 0.5s;
}
span {
opacity: 0.8;
transition: all 0.5s;
}
&:hover {
span {
opacity: 1;
}
}
&:hover {
span {
opacity: 1;
}
}
}
.nav-tabs .nav-link {
@include media-breakpoint-down(xl) {
padding-left: 10px;
padding-right: 10px;
}
@include media-breakpoint-down(xl) {
padding-left: 10px;
padding-right: 10px;
}
}
:not(.text-view) > a:not(.no-icon) {
&[href^="http://"],
&[href^="https://"]
{
&:after {
content: "\f1c5";
display: inline-block;
font-family: "bootstrap-icons" !important;
font-style: normal;
font-weight: normal !important;
font-variant: normal;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
margin-left: 4px;
}
}
&[href^="http://"],
&[href^="https://"]
{
&:after {
content: "\f1c5";
display: inline-block;
font-family: "bootstrap-icons" !important;
font-style: normal;
font-weight: normal !important;
font-variant: normal;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
margin-left: 4px;
}
}
}
#loading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
.loader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
}
// dark mode adjustments
@include color-mode(dark) {
#loading {
background: rgba(0, 0, 0, 0.4);
}
.loader {
background: rgba(0, 0, 0, 0.4);
}
.token.tag,
.token.property {
color: #ee6969;
}
.token.tag,
.token.property {
color: #ee6969;
}
}
.about-mailpit {
@include media-breakpoint-down(md) {
width: var(--bs-offcanvas-width);
margin-left: -1rem !important;
}
}
.message {
&.read {
color: $text-muted;
&.read {
color: $text-muted;
b {
font-weight: normal;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
b {
font-weight: normal;
}
}
&.selected {
background: var(--bs-primary-bg-subtle);
}
}
#nav-plain-text .text-view,
#nav-source {
white-space: pre;
font-family:
Courier New,
Courier,
System,
fixed-width;
font-size: 0.85em;
white-space: pre;
font-family: "Courier New", Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-html-source pre[class*="language-"] code {
white-space: pre-wrap;
white-space: pre-wrap;
}
#nav-plain-text .text-view {
white-space: pre-wrap;
white-space: pre-wrap;
}
.messageHeaders {
margin: 15px 0 0;
margin: 15px 0 0;
th {
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
th {
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
td {
vertical-align: top;
}
td {
vertical-align: top;
}
}
#nav-html {
@include media-breakpoint-up(md) {
padding-right: 1.5rem;
}
@include media-breakpoint-up(md) {
padding-right: 1.5rem;
}
}
#preview-html {
min-height: 300px;
min-height: 300px;
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
&.tablet,
&.phone {
border: solid $gray-300 1px;
}
}
#responsive-view {
margin: auto;
transition: width 0.5s;
position: relative;
margin: auto;
transition: width 0.5s;
position: relative;
&.tablet,
&.phone {
border-radius: 35px;
box-sizing: content-box;
padding-bottom: 76px;
padding-top: 54px;
padding-left: 10px;
padding-right: 10px;
background: $gray-800;
&.tablet,
&.phone {
border-radius: 35px;
box-sizing: content-box;
padding-bottom: 76px;
padding-top: 54px;
padding-left: 10px;
padding-right: 10px;
background: $gray-800;
iframe {
height: 100% !important;
background: #fff;
}
}
iframe {
height: 100% !important;
background: #fff;
}
}
&.phone {
&::before {
border-radius: 5px;
background: $gray-600;
top: 22px;
content: "";
display: block;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 80px;
}
&.phone {
&::before {
border-radius: 5px;
background: $gray-600;
top: 22px;
content: "";
display: block;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 80px;
}
&::after {
border-radius: 20px;
background: $gray-900;
bottom: 20px;
content: "";
display: block;
width: 65px;
height: 40px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&::after {
border-radius: 20px;
background: $gray-900;
bottom: 20px;
content: "";
display: block;
width: 65px;
height: 40px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&.tablet {
&::before {
border-radius: 50%;
border: solid #b5b0b0 2px;
top: 22px;
content: "";
display: block;
width: 10px;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
&.tablet {
&::before {
border-radius: 50%;
border: solid #b5b0b0 2px;
top: 22px;
content: "";
display: block;
width: 10px;
height: 10px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
&::after {
border-radius: 50%;
border: solid #b5b0b0 2px;
bottom: 23px;
content: "";
display: block;
width: 30px;
height: 30px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
&::after {
border-radius: 50%;
border: solid #b5b0b0 2px;
bottom: 23px;
content: "";
display: block;
width: 30px;
height: 30px;
left: 50%;
position: absolute;
transform: translateX(-50%);
}
}
}
.list-group-item.message:first-child {
border-top: 0;
border-top: 0;
}
body.blur {
.privacy {
filter: blur(3px);
}
.privacy {
filter: blur(3px);
}
}
.card.attachment {
color: $gray-800;
color: $gray-800;
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-footer {
background: $gray-300;
.card-footer {
background: $gray-300;
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
}
.form-select.tag-selector {
display: none;
display: none;
}
.form-control.dropdown {
padding: 0;
border: 0;
padding: 0;
border: 0;
input {
font-size: 0.875em;
}
input {
font-size: 0.875em;
}
div {
cursor: text; // html5-tags
}
div {
cursor: text; // html5-tags
}
}
// bootstrap5-tags
.tags-badge {
display: flex;
display: flex;
}
#DownloadBtn {
@include media-breakpoint-down(sm) {
position: static;
@include media-breakpoint-down(sm) {
position: static;
.dropdown-menu {
left: 0;
right: 0;
}
}
.dropdown-menu {
left: 0;
right: 0;
}
}
}
#ReleaseModal {
.form-control.dropdown {
div {
@extend .form-control;
}
}
.form-control.dropdown {
div {
@extend .form-control;
}
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
code[class*="language-"],
pre[class*="language-"] {
// color: #000;
// background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
// color: #000;
// background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
position: relative;
overflow: visible;
position: relative;
overflow: visible;
}
pre[class*="language-"] > code {
position: relative;
z-index: 1;
position: relative;
z-index: 1;
}
code[class*="language-"] {
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
// background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
// background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
}
:not(pre) > code[class*="language-"] {
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
}
.token.block-comment,
@ -375,10 +378,10 @@ pre[class*="language-"] {
.token.comment,
.token.doctype,
.token.prolog {
color: #7d8b99;
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
color: #5f6364;
}
.token.boolean,
.token.constant,
@ -388,7 +391,7 @@ pre[class*="language-"] {
.token.property,
.token.symbol,
.token.tag {
color: #c92c2c;
color: #c92c2c;
}
.token.attr-name,
.token.builtin,
@ -397,70 +400,70 @@ pre[class*="language-"] {
.token.inserted,
.token.selector,
.token.string {
color: #2f9c0a;
color: #2f9c0a;
}
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.keyword {
color: #1990b8;
color: #1990b8;
}
.token.important,
.token.regex {
color: #e90;
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
color: #a67f59;
// background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
font-weight: 400;
}
.token.bold {
font-weight: 700;
font-weight: 700;
}
.token.italic {
font-style: italic;
font-style: italic;
}
// .token.entity {
// cursor: help;
// }
.token.namespace {
opacity: 0.7;
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]::after,
pre[class*="language-"]::before {
bottom: 14px;
box-shadow: none;
}
pre[class*="language-"]::after,
pre[class*="language-"]::before {
bottom: 14px;
box-shadow: none;
}
}
pre[class*="language-"].line-numbers.line-numbers {
padding-left: 0;
padding-left: 0;
}
pre[class*="language-"].line-numbers.line-numbers code {
padding-left: 3.8em;
padding-left: 3.8em;
}
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
left: 0;
left: 0;
}
pre[class*="language-"][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
margin-top: 0;
}

View File

@ -0,0 +1,239 @@
<script>
import AjaxLoader from './AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
components: {
AjaxLoader
},
props: {
modals: {
type: Boolean,
default: false,
}
},
data() {
return {
mailbox,
theme: 'auto',
icon: 'circle-half',
icons: {
'auto': 'circle-half',
'light': 'sun-fill',
'dark': 'moon-stars-fill'
},
}
},
mounted() {
this.setTheme(this.getPreferredTheme())
},
methods: {
loadInfo: function () {
let self = this
self.get(self.resolve('/api/v1/info'), false, function (response) {
mailbox.appInfo = response.data
self.modal('AppInfoModal').show()
})
},
getStoredTheme: function () {
let theme = localStorage.getItem('theme')
if (!theme) {
theme = 'auto'
}
return theme
},
setStoredTheme: function (theme) {
localStorage.setItem('theme', theme)
this.setTheme(theme)
},
getPreferredTheme: function () {
const storedTheme = this.getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
},
setTheme: function (theme) {
this.icon = this.icons[theme]
this.theme = theme
if (
theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
},
requestNotifications: function () {
// check if the browser supports notifications
if (!("Notification" in window)) {
alert("This browser does not support desktop notification")
}
// we need to ask the user for permission
else if (Notification.permission !== "denied") {
let self = this
Notification.requestPermission().then(function (permission) {
if (permission === "granted") {
mailbox.notificationsEnabled = true
}
})
}
},
}
}
</script>
<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">
<i class="bi bi-info-circle-fill me-1"></i>
About
</button>
<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false"
title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme">
<i :class="'bi bi-' + icon + ' my-1'"></i>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')">
<i class="bi bi-sun-fill me-2 opacity-50"></i>
Light
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')">
<i class="bi bi-moon-stars-fill me-2 opacity-50"></i>
Dark
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')">
<i class="bi bi-circle-half me-2 opacity-50"></i>
Auto
</button>
</li>
</ul>
</div>
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
data-bs-target="#EnableNotificationsModal" title="Enable browser notifications"
v-if="mailbox.connected && mailbox.notificationsSupported && !mailbox.notificationsEnabled">
<i class="bi bi-bell"></i>
</button>
</div>
</template>
<template v-else>
<!-- Modals -->
<div class="modal fade" id="AppInfoModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header" v-if="mailbox.appInfo">
<h5 class="modal-title" id="AppInfoModalLabel">
Mailpit
<code>({{ mailbox.appInfo.Version }})</code>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<a class="btn btn-warning d-block mb-3"
v-if="mailbox.appInfo.Version != mailbox.appInfo.LatestVersion"
:href="'https://github.com/axllent/mailpit/releases/tag/' + mailbox.appInfo.LatestVersion">
A new version of Mailpit ({{ mailbox.appInfo.LatestVersion }}) is available.
</a>
<div class="row g-3">
<div class="col-12">
<RouterLink to="/api/v1/" class="btn btn-primary w-100" target="_blank">
<i class="bi bi-braces"></i>
OpenAPI / Swagger API documentation
</RouterLink>
</div>
<div class="col-sm-6">
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit" target="_blank">
<i class="bi bi-github"></i>
Github
</a>
</div>
<div class="col-sm-6">
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit/wiki"
target="_blank">
Documentation
</a>
</div>
<div class="col-6">
<div class="card border-secondary text-center">
<div class="card-header">Database size</div>
<div class="card-body text-secondary">
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.DatabaseSize) }} </h5>
</div>
</div>
</div>
<div class="col-6">
<div class="card border-secondary text-center">
<div class="card-header">RAM usage</div>
<div class="card-body text-secondary">
<h5 class="card-title">{{ getFileSize(mailbox.appInfo.Memory) }} </h5>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="h4">Get browser notifications when Mailpit receives new messages?</p>
<p>
Note that your browser will ask you for confirmation when you click
<code>enable notifications</code>,
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
v-on:click="requestNotifications">Enable notifications</button>
</div>
</div>
</div>
</div>
</template>
<AjaxLoader :loading="loading" />
</template>

View File

@ -0,0 +1,17 @@
<script>
export default {
props: {
loading: Number,
},
}
</script>
<template>
<div class="loader" v-if="loading > 0">
<div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,166 @@
<script>
import { mailbox } from '../stores/mailbox'
import CommonMixins from '../mixins/CommonMixins'
import moment from 'moment'
export default {
mixins: [
CommonMixins
],
data() {
return {
mailbox,
}
},
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"
}
})
},
methods: {
getRelativeCreated: function (message) {
let d = new Date(message.Created)
return moment(d).fromNow().toString()
},
getPrimaryEmailTo: function (message) {
for (let i in message.To) {
return message.To[i].Address
}
return '[ Undisclosed recipients ]'
},
isSelected: function (id) {
return mailbox.selected.indexOf(id) != -1
},
toggleSelected: function (e, id) {
e.preventDefault()
if (this.isSelected(id)) {
mailbox.selected = mailbox.selected.filter(function (ele) {
return ele != id
})
} else {
mailbox.selected.push(id)
}
},
selectRange: function (e, id) {
e.preventDefault()
let selecting = false
let lastSelected = mailbox.selected.length > 0 && mailbox.selected[mailbox.selected.length - 1]
if (lastSelected == id) {
mailbox.selected = mailbox.selected.filter(function (ele) {
return ele != id
})
return
}
if (lastSelected === false) {
mailbox.selected.push(id)
return
}
for (let d of mailbox.messages) {
if (selecting) {
if (!this.isSelected(d.ID)) {
mailbox.selected.push(d.ID)
}
if (d.ID == lastSelected || d.ID == id) {
// reached backwards select
break
}
} else if (d.ID == id || d.ID == lastSelected) {
if (!this.isSelected(d.ID)) {
mailbox.selected.push(d.ID)
}
selecting = true
}
}
},
}
}
</script>
<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"
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)">
<div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small">
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="message.From.Address">{{
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="message.From.Address">{{
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) }}
<span v-if="message.To && message.To.length > 1">
[+{{ message.To.length - 1 }}]
</span>
</div>
</div>
<div class="col-lg-6 col-xxl-7 mt-2 mt-lg-0">
<div><b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b></div>
<div>
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="'/search?q=' + tagEncodeURI(t)"
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
:title="'Filter messages tagged with ' + t">
{{ t }}
</RouterLink>
</div>
</div>
<div class="d-none d-lg-block col-1 small text-end text-muted">
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
{{ getFileSize(message.Size) }}
</div>
<div class="d-none d-lg-block col-2 col-xxl-1 small text-end text-muted">
{{ getRelativeCreated(message) }}
</div>
<!-- </a> -->
</RouterLink>
</div>
</template>
<template v-else>
<p class="text-center mt-5">
<template v-if="getSearch()">No results for <code>{{ getSearch() }}</code></template>
<template v-else>No messages in your mailbox</template>
</p>
</template>
</template>

View File

@ -0,0 +1,139 @@
<script>
import NavSelected from '../components/NavSelected.vue'
import AjaxLoader from "./AjaxLoader.vue"
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
components: {
NavSelected,
AjaxLoader,
},
props: {
modals: {
type: Boolean,
default: false,
}
},
emits: ['loadMessages'],
data() {
return {
mailbox,
pagination,
}
},
methods: {
reloadInbox: function () {
pagination.start = 0
this.loadMessages()
},
loadMessages: function () {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
markAllRead: function () {
let self = this
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
deleteAllMessages: function () {
let self = this
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
pagination.start = 0
self.loadMessages()
})
}
}
}
</script>
<template>
<template v-if="!modals">
<div class="list-group my-2">
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
<span class="ms-1">Inbox</span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
v-if="mailbox.unread">
{{ formatNumber(mailbox.unread) }}
</span>
</button>
<template v-if="!mailbox.selected.length">
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#MarkAllReadModal" :disabled="!mailbox.unread">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
</template>
<NavSelected @loadMessages="loadMessages" />
</div>
</template>
<template v-else>
<!-- Modals -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will mark {{ formatNumber(mailbox.unread) }}
message<span v-if="mailbox.unread > 1">s</span> as read.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal"
v-on:click="markAllRead">Confirm</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(mailbox.count) }}
message<span v-if="mailbox.count > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
v-on:click="deleteAllMessages">Delete</button>
</div>
</div>
</div>
</div>
</template>
<AjaxLoader :loading="loading" />
</template>

View File

@ -0,0 +1,115 @@
<script>
import NavSelected from '../components/NavSelected.vue'
import AjaxLoader from './AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
components: {
NavSelected,
AjaxLoader,
},
props: {
modals: {
type: Boolean,
default: false,
}
},
emits: ['loadMessages'],
data() {
return {
mailbox,
pagination,
}
},
methods: {
loadMessages: function () {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
deleteAllMessages: function () {
let s = this.getSearch()
if (!s) {
return
}
let self = this
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, function (response) {
self.$router.push('/')
})
}
}
}
</script>
<template>
<template v-if="!modals">
<div class="list-group my-2">
<RouterLink to="/" class="list-group-item list-group-item-action">
<i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">Inbox</span>
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
v-if="mailbox.unread">
{{ formatNumber(mailbox.unread) }}
</span>
</RouterLink>
<template v-if="!mailbox.selected.length">
<button class="list-group-item list-group-item-action" data-bs-toggle="modal"
data-bs-target="#DeleteAllModal" :disabled="!mailbox.count">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button>
</template>
<!-- <button class="list-group-item list-group-item-action" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal"
:disabled="!mailbox.unread">
<i class="bi bi-eye-fill me-1"></i>
Mark all read
</button>
<button class="list-group-item list-group-item-action" data-bs-toggle="modal" data-bs-target="#DeleteAllModal"
:disabled="!mailbox.total">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</button> -->
<NavSelected @loadMessages="loadMessages" />
</div>
</template>
<template v-else>
<!-- Modals -->
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages matching search?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(mailbox.count) }}
message<span v-if="mailbox.count > 1">s</span> matching
<code>{{ getSearch() }}</code>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal"
v-on:click="deleteAllMessages">Delete</button>
</div>
</div>
</div>
</div>
</template>
<AjaxLoader :loading="loading" />
</template>

View File

@ -0,0 +1,120 @@
<script>
import AjaxLoader from './AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
components: {
AjaxLoader,
},
emits: ['loadMessages'],
data() {
return {
mailbox,
}
},
methods: {
loadMessages: function () {
this.$emit('loadMessages')
},
// mark selected messages as read
markSelectedRead: function () {
let self = this
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'read': true, 'ids': mailbox.selected }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
isSelected: function (id) {
return mailbox.selected.indexOf(id) != -1
},
// mark selected messages as unread
markSelectedUnread: function () {
let self = this
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'read': false, 'ids': mailbox.selected }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
// universal handler to delete current or selected messages
deleteMessages: function () {
let ids = []
let self = this
ids = JSON.parse(JSON.stringify(mailbox.selected))
if (!ids.length) {
return false
}
self.delete(self.resolve(`/api/v1/messages`), { 'ids': ids }, function (response) {
window.scrollInPlace = true
self.loadMessages()
})
},
// test if any selected emails are unread
selectedHasUnread: function () {
if (!mailbox.selected.length) {
return false
}
for (let i in mailbox.messages) {
if (this.isSelected(mailbox.messages[i].ID) && !mailbox.messages[i].Read) {
return true
}
}
return false
},
// test of any selected emails are read
selectedHasRead: function () {
if (!mailbox.selected.length) {
return false
}
for (let i in mailbox.messages) {
if (this.isSelected(mailbox.messages[i].ID) && mailbox.messages[i].Read) {
return true
}
}
return false
},
}
}
</script>
<template>
<template v-if="mailbox.selected.length">
<button class="list-group-item list-group-item-action" :disabled="!selectedHasUnread()"
v-on:click="markSelectedRead">
<i class="bi bi-eye-fill me-1"></i>
Mark read
</button>
<button class="list-group-item list-group-item-action" :disabled="!selectedHasRead()"
v-on:click="markSelectedUnread">
<i class="bi bi-eye-slash me-1"></i>
Mark unread
</button>
<button class="list-group-item list-group-item-action" v-on:click="deleteMessages()">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete selected
</button>
<button class="list-group-item list-group-item-action" v-on:click="mailbox.selected = []">
<i class="bi bi-x-circle me-1"></i>
Cancel selection
</button>
</template>
<AjaxLoader :loading="loading" />
</template>

View File

@ -0,0 +1,55 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins],
data() {
return {
mailbox,
}
},
methods: {
inSearch: function (tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
return false
}
let re = new RegExp(`\\btag:"?${tag}"?\\b`, 'i')
return query.match(re)
}
}
}
</script>
<template>
<template v-if="mailbox.tags && mailbox.tags.length">
<div class="mt-4 text-muted">
<button class="btn btn-sm dropdown-toggle ms-n1" data-bs-toggle="dropdown" aria-expanded="false">
Tags
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @click="mailbox.showTagColors = !mailbox.showTagColors">
<template v-if="mailbox.showTagColors">Hide</template>
<template v-else>Show</template>
tag colors
</button>
</li>
</ul>
</div>
<div class="list-group mt-1 mb-5 pb-3">
<RouterLink v-for="tag in mailbox.tags" :to="'/search?q=' + tagEncodeURI(tag)" @click="hideNav"
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
class="list-group-item list-group-item-action small px-2" :class="inSearch(tag) ? 'active' : ''">
<i class="bi bi-tag-fill" v-if="inSearch(tag)"></i>
<i class="bi bi-tag" v-else></i>
{{ tag }}
</RouterLink>
</div>
</template>
</template>

View File

@ -0,0 +1,176 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { Toast } from 'bootstrap'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
data() {
return {
pagination,
mailbox,
toastMessage: false,
reconnectRefresh: false,
socketURI: false,
pauseNotifications: false, // prevent spamming
}
},
mounted() {
let proto = location.protocol == 'https:' ? 'wss' : 'ws'
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
this.connect()
mailbox.notificationsSupported = window.isSecureContext
&& ("Notification" in window && Notification.permission !== "denied")
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
},
methods: {
// websocket connect
connect: function () {
let ws = new WebSocket(this.socketURI)
let self = this
ws.onmessage = function (e) {
let response = JSON.parse(e.data)
if (!response) {
return
}
// new messages
if (response.Type == "new" && response.Data) {
if (!mailbox.searching) {
if (pagination.start < 1) {
// push results directly into first page
mailbox.messages.unshift(response.Data)
if (mailbox.messages.length > pagination.limit) {
mailbox.messages.pop()
}
} else {
// update pagination offset
pagination.start++
}
}
for (let i in response.Data.Tags) {
if (mailbox.tags.indexOf(response.Data.Tags[i]) < 0) {
mailbox.tags.push(response.Data.Tags[i])
mailbox.tags.sort()
}
}
// send notifications
if (!self.pauseNotifications) {
self.pauseNotifications = true
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
self.browserNotify("New mail from: " + from, response.Data.Subject)
self.setMessageToast(response.Data)
// delay notifications by 2s
window.setTimeout(() => { self.pauseNotifications = false }, 2000)
}
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
window.scrollInPlace = true
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
} else if (response.Type == "stats" && response.Data) {
// refresh mailbox stats
mailbox.total = response.Data.Total
mailbox.unread = response.Data.Unread
}
}
ws.onopen = function () {
mailbox.connected = true
if (self.reconnectRefresh) {
self.reconnectRefresh = false
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
}
}
ws.onclose = function (e) {
mailbox.connected = false
self.reconnectRefresh = true
setTimeout(function () {
self.connect() // reconnect
}, 1000)
}
ws.onerror = function (err) {
ws.close()
}
},
browserNotify: function (title, message) {
if (!("Notification" in window)) {
return
}
if (Notification.permission === "granted") {
let b = message.Subject
let options = {
body: message,
icon: this.resolve('/notification.png')
}
new Notification(title, options)
}
},
setMessageToast: function (m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (mailbox.notificationsEnabled || this.toastMessage) {
return
}
this.toastMessage = m
let self = this
let el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.toastMessage = false
})
Toast.getOrCreateInstance(el).show()
}
},
closeToast: function () {
let el = document.getElementById('messageToast')
if (el) {
Toast.getOrCreateInstance(el).hide()
}
},
},
}
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header" v-if="toastMessage">
<i class="bi bi-envelope-exclamation-fill me-2"></i>
<strong class="me-auto">
<RouterLink :to="'/view/' + toastMessage.ID" @click="closeToast">New message</RouterLink>
</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div>
<RouterLink :to="'/view/' + toastMessage.ID" class="d-block text-truncate text-body-secondary"
@click="closeToast">
<template v-if="toastMessage.Subject != ''">{{ toastMessage.Subject }}</template>
<template v-else>
[ no subject ]
</template>
</RouterLink>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,92 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
props: {
total: Number,
},
emits: ['loadMessages'],
data() {
return {
pagination,
mailbox,
}
},
computed: {
canPrev: function () {
return pagination.start > 0
},
canNext: function () {
return this.total > (pagination.start + mailbox.messages.length)
},
// returns the number of next X messages
nextMessages: function () {
let t = pagination.start + parseInt(pagination.limit, 10)
if (t > this.total) {
t = this.total
}
return t
},
},
methods: {
changeLimit: function () {
pagination.start = 0
this.$emit('loadMessages')
},
viewNext: function () {
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
this.$emit('loadMessages')
},
viewPrev: function () {
let s = pagination.start - pagination.limit
if (s < 0) {
s = 0
}
pagination.start = s
this.$emit('loadMessages')
},
}
}
</script>
<template>
<select v-model="pagination.limit" @change="changeLimit" class="form-select form-select-sm d-inline w-auto me-2"
:disabled="total == 0">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<small>
<template v-if="total > 0">
{{ formatNumber(pagination.start + 1) }}-{{ formatNumber(nextMessages) }}
<small>of</small>
{{ formatNumber(total) }}
</template>
<span v-else class="text-muted">0 of 0</span>
</small>
<button class="btn btn-outline-light ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
:title="'View previous ' + pagination.limit + ' messages'">
<i class="bi bi-caret-left-fill"></i>
</button>
<button class="btn btn-outline-light" :disabled="!canNext" v-on:click="viewNext"
:title="'View next ' + pagination.limit + ' messages'">
<i class="bi bi-caret-right-fill"></i>
</button>
</template>

View File

@ -0,0 +1,64 @@
<script>
import CommonMixins from '../mixins/CommonMixins'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
data() {
return {
search: '',
pagination
}
},
mounted() {
this.searchFromURL()
},
watch: {
$route() {
this.searchFromURL()
}
},
methods: {
searchFromURL: function () {
const urlParams = new URLSearchParams(window.location.search)
this.search = urlParams.get('q') ? urlParams.get('q') : ''
},
doSearch: function (e) {
pagination.start = 0
if (this.search == '') {
this.$router.push('/')
} else {
this.$router.push('/search?q=' + encodeURIComponent(this.search))
}
e.preventDefault()
},
resetSearch: function () {
this.search = ''
this.$router.push('/')
}
}
}
</script>
<template>
<form v-on:submit="doSearch">
<div class="input-group flex-nowrap">
<div class="ms-md-2 d-flex border bg-body rounded-start flex-fill position-relative">
<input type="text" class="form-control border-0" aria-label="Search" v-model.trim="search"
placeholder="Search mailbox">
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search != ''"
v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
</div>
<button class="btn btn-outline-secondary" type="submit">
<i class="bi bi-search"></i>
</button>
</div>
</form>
</template>

View File

@ -1,6 +1,6 @@
<script>
import commonMixins from '../mixins.js';
import commonMixins from '../../mixins/CommonMixins'
export default {
props: {
@ -14,9 +14,9 @@ export default {
<template>
<div class="mt-4 border-top pt-4">
<a v-for="part in attachments" :href="'api/v1/message/' + message.ID + '/part/' + part.PartID"
<a v-for="part in attachments" :href="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID)"
class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
<img v-if="isImage(part)" :src="'api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb'"
<img v-if="isImage(part)" :src="resolve('/api/v1/message/' + message.ID + '/part/' + part.PartID + '/thumb')"
class="card-img-top" alt="">
<img v-else
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg=="

View File

@ -1,7 +1,7 @@
<script>
import axios from 'axios'
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
import commonMixins from '../mixins.js'
import axios from 'axios'
import commonMixins from '../../mixins/CommonMixins'
import { Tooltip } from 'bootstrap'
export default {
@ -225,7 +225,7 @@ export default {
let self = this
// ignore any error, do not show loader
axios.get('api/v1/message/' + self.message.ID + '/html-check', null)
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/html-check'), null)
.then(function (result) {
self.check = result.data
self.error = false

View File

@ -1,6 +1,6 @@
<script>
import commonMixins from '../mixins.js'
import commonMixins from '../../mixins/CommonMixins'
export default {
props: {
@ -17,7 +17,7 @@ export default {
mounted() {
let self = this;
let uri = 'api/v1/message/' + self.message.ID + '/headers'
let uri = self.resolve('/api/v1/message/' + self.message.ID + '/headers')
self.get(uri, false, function (response) {
self.headers = response.data
});
@ -28,10 +28,10 @@ export default {
<template>
<div v-if="headers" class="small">
<div v-for="vals, k in headers" class="row mb-2 pb-2 border-bottom w-100">
<div v-for="values, k in headers" class="row mb-2 pb-2 border-bottom w-100">
<div class="col-md-4 col-lg-3 col-xl-2 mb-2"><b>{{ k }}</b></div>
<div class="col-md-8 col-lg-9 col-xl-10 text-body-secondary">
<div v-for="x in vals" class="mb-2 text-break">{{ x }}</div>
<div v-for="x in values" class="mb-2 text-break">{{ x }}</div>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
<script>
import axios from 'axios'
import commonMixins from '../mixins.js'
import commonMixins from '../../mixins/CommonMixins'
export default {
props: {
@ -116,13 +116,13 @@ export default {
methods: {
doCheck: function () {
this.check = false
let self = this
this.loading = true
let uri = 'api/v1/message/' + self.message.ID + '/link-check'
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
if (this.followRedirects) {
uri += '?follow=true'
}
let self = this
// ignore any error, do not show loader
axios.get(uri, null)
.then(function (result) {

View File

@ -1,18 +1,17 @@
<script>
import commonMixins from '../mixins.js'
import Prism from "prismjs"
import Tags from "bootstrap5-tags"
import Attachments from './Attachments.vue'
import HTMLCheck from './HTMLCheck.vue'
import Headers from './Headers.vue'
import HTMLCheck from './MessageHTMLCheck.vue'
import LinkCheck from './MessageLinkCheck.vue'
import LinkCheck from './LinkCheck.vue'
import Prism from 'prismjs'
import Tags from 'bootstrap5-tags'
import commonMixins from '../../mixins/CommonMixins'
import { mailbox } from '../../stores/mailbox'
export default {
props: {
message: Object,
existingTags: Array,
uiConfig: Object
},
components: {
@ -26,12 +25,11 @@ export default {
data() {
return {
mailbox,
srcURI: false,
iframes: [], // for resizing
showTags: false, // to force re-rendering of component
canSaveTags: false, // prevent auto-saving tags on render
messageTags: [],
allTags: [],
loadHeaders: false,
htmlScore: false,
htmlScoreColor: false,
@ -67,9 +65,7 @@ export default {
mounted() {
let self = this
self.showTags = false
self.canSaveTags = false
self.allTags = self.existingTags
self.messageTags = self.message.Tags
self.renderUI()
@ -82,12 +78,13 @@ export default {
let rawTab = document.getElementById('nav-raw-tab')
rawTab.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw'
self.srcURI = self.resolve('/api/v1/message/' + self.message.ID + '/raw')
self.resizeIFrames()
})
self.showTags = true
self.$nextTick(function () {
// manually refresh tags
self.get(self.resolve(`/api/v1/tags`), false, function (response) {
mailbox.tags = response.data
self.$nextTick(function () {
Tags.init('select[multiple]')
// delay tag change detection to allow Tags to load
@ -188,8 +185,8 @@ export default {
tags: this.messageTags
}
self.put('api/v1/tags', data, function (response) {
self.scrollInPlace = true
self.put(self.resolve('/api/v1/tags'), data, function (response) {
window.scrollInPlace = true
self.$emit('loadMessages')
})
},
@ -304,7 +301,7 @@ export default {
data-separator="|,|">
<option value="">Type a tag...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in allTags" :value="t">{{ t }}</option>
<option v-for="t in mailbox.tags" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid tag name</div>
</td>
@ -362,7 +359,7 @@ export default {
role="tab" aria-controls="nav-raw" aria-selected="false">
Raw
</button>
<div class="dropdown d-lg-none">
<div class="dropdown d-xl-none">
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Checks
</button>
@ -370,7 +367,7 @@ export default {
<li>
<button class="dropdown-item" id="nav-html-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
aria-selected="false" v-if="!uiConfig.DisableHTMLCheck && message.HTML != ''">
aria-selected="false" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
HTML Check
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
<small>{{ Math.floor(htmlScore) }}%</small>
@ -390,15 +387,15 @@ export default {
</li>
</ul>
</div>
<button class="d-none d-lg-inline-block nav-link position-relative" id="nav-html-check-tab"
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html"
aria-selected="false" v-if="!uiConfig.DisableHTMLCheck && message.HTML != ''">
aria-selected="false" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
HTML Check
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
<small>{{ Math.floor(htmlScore) }}%</small>
</span>
</button>
<button class="d-none d-lg-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
<button class="d-none d-xl-inline-block nav-link" id="nav-link-check-tab" data-bs-toggle="tab"
data-bs-target="#nav-link-check" type="button" role="tab" aria-controls="nav-link-check"
aria-selected="false">
Link Check
@ -449,7 +446,7 @@ 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="!uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
<HTMLCheck v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
</div>
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"

View File

@ -0,0 +1,135 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import Tags from "bootstrap5-tags"
import commonMixins from '../../mixins/CommonMixins'
import { mailbox } from '../../stores/mailbox'
export default {
props: {
message: Object,
},
components: {
AjaxLoader,
},
data() {
return {
addresses: [],
mailbox,
allAddresses: [],
}
},
mixins: [commonMixins],
mounted() {
let a = []
for (let i in this.message.To) {
a.push(this.message.To[i].Address)
}
for (let i in this.message.Cc) {
a.push(this.message.Cc[i].Address)
}
for (let i in this.message.Bcc) {
a.push(this.message.Bcc[i].Address)
}
// include only unique email addresses, regardless of casing
this.allAddresses = JSON.parse(JSON.stringify([...new Map(a.map(ad => [ad.toLowerCase(), ad])).values()]))
this.addresses = this.allAddresses
},
methods: {
// triggered manually after modal is shown
initTags: function () {
Tags.init("select[multiple]")
},
releaseMessage: function () {
let self = this
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
return false
}
let data = {
to: self.addresses
}
self.post(self.resolve('/api/v1/message/' + self.message.ID + '/release'), data, function (response) {
self.modal("ReleaseModal").hide()
})
}, 100)
}
}
}
</script>
<template>
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" v-if="message">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h6>Send this message to one or more addresses specified below.</h6>
<div class="row">
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
<div class="col-sm-10">
<input type="text" aria-label="From address" readonly class="form-control-plaintext"
:value="message.From.Address">
</div>
</div>
<div class="row">
<label class=" col-sm-2 col-form-label text-body-secondary">Subject</label>
<div class="col-sm-10">
<input type="text" aria-label="Subject" readonly class="form-control-plaintext"
:value="message.Subject">
</div>
</div>
<div class="row mb-3">
<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-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>
<!-- you need at least one option with the placeholder -->
<option v-for="t in allAddresses" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid email address</div>
</div>
</div>
<div class="form-text text-center" v-if="mailbox.uiConfig.MessageRelay.RecipientAllowlist != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
<br class="d-none d-md-inline">
Configured allowlist: <b>{{ mailbox.uiConfig.MessageRelay.RecipientAllowlist }}</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-else>{{ message.ReturnPath }}</b>.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
v-on:click="releaseMessage">Release</button>
</div>
</div>
</div>
</div>
<AjaxLoader :loading="loading" />
</template>

View File

@ -1,5 +1,7 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import CommonMixins from '../../mixins/CommonMixins'
import { domToPng } from 'modern-screenshot'
export default {
@ -7,19 +9,26 @@ export default {
message: Object,
},
mixins: [CommonMixins],
components: {
AjaxLoader,
},
data() {
return {
html: false,
loading: false
loading: 0
}
},
methods: {
initScreenshot: function () {
this.loading = true
this.loading = 1
let self = this
// remove base tag, if set
let h = this.message.HTML.replace(/<base .*>/mi, '')
let proxy = this.resolve('/proxy')
// Outlook hacks - else screenshot returns blank image
h = h.replace(/<html [^>]+>/mgi, '<html>') // remove html attributes
@ -31,9 +40,9 @@ export default {
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
h = h.replaceAll(urlRegex, function (match, p1, p2, p3) {
if (typeof p2 === 'string') {
return `url(${p2}proxy?url=` + encodeURIComponent(self.decodeEntities(p3)) + `${p2})`
return `url(${p2}${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `${p2})`
}
return `url(proxy?url=` + encodeURIComponent(self.decodeEntities(p3)) + `)`
return `url(${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `)`
})
// create temporary document to manipulate
@ -54,7 +63,7 @@ export default {
let src = i.getAttribute('href')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('href', 'proxy?url=' + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
}
}
@ -63,7 +72,7 @@ export default {
for (let i of images) {
let src = i.getAttribute('src')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('src', 'proxy?url=' + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
}
}
@ -74,7 +83,7 @@ export default {
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
// replace with proxy link
i.setAttribute('background', 'proxy?url=' + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
}
}
@ -121,7 +130,7 @@ export default {
link.download = self.message.ID + '.png'
link.href = dataUrl
link.click()
self.loading = false
self.loading = 0
self.html = false
})
}
@ -134,11 +143,5 @@ export default {
style="position: absolute; margin-left: -100000px;">
</iframe>
<div id="loading" v-if="loading">
<div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<AjaxLoader :loading="loading" />
</template>

View File

@ -1,44 +1,66 @@
import axios from 'axios'
import { Modal } from 'bootstrap'
import moment from 'moment'
import ColorHash from 'color-hash'
import { Modal, Offcanvas } from 'bootstrap'
// FakeModal is used to return a fake Bootstrap modal
// BootstrapElement is used to return a fake Bootstrap element
// if the ID returns nothing to prevent errors.
function FakeModal() { }
FakeModal.prototype.hide = function () { }
FakeModal.prototype.show = function () { }
class BootstrapElement {
constructor() { }
hide() { }
show() { }
}
// Set up the color hash generator lightness and hue to ensure darker colors
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] });
const colorHash = new ColorHash({ lightness: 0.3, saturation: [0.35, 0.5, 0.65] })
/* Common mixin functions used in apps */
const commonMixins = {
export default {
data() {
return {
loading: 0,
tagColorCache: {},
showTagColors: true
}
},
mounted() {
this.showTagColors = localStorage.getItem('showTagsColors')
},
methods: {
resolve: function (u) {
return this.$router.resolve(u).href
},
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
var i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
},
formatNumber: function (nr) {
return new Intl.NumberFormat().format(nr);
return new Intl.NumberFormat().format(nr)
},
messageDate: function (d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
return moment(d).format('ddd, D MMM YYYY, h:mm a')
},
tagEncodeURI: function (tag) {
if (tag.match(/ /)) {
tag = `"${tag}"`
}
return encodeURIComponent(`tag:${tag}`)
},
getSearch: function () {
if (!window.location.search) {
return false
}
const urlParams = new URLSearchParams(window.location.search)
const q = urlParams.get('q').trim()
if (q == '') {
return false
}
return q
},
// Ajax error message
@ -48,29 +70,37 @@ const commonMixins = {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
alert(error.response.data.Error);
alert(error.response.data.Error)
} else {
alert(error.response.data);
alert(error.response.data)
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
alert('Error sending data to the server. Please try again.');
alert('Error sending data to the server. Please try again.')
} else {
// Something happened in setting up the request that triggered an Error
alert(error.message);
alert(error.message)
}
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id);
let e = document.getElementById(id)
if (e) {
return Modal.getOrCreateInstance(e);
return Modal.getOrCreateInstance(e)
}
// in case there are open/close actions
return new FakeModal();
return new BootstrapElement()
},
// close mobile navigation
hideNav: function () {
let e = document.getElementById('offcanvas')
if (e) {
Offcanvas.getOrCreateInstance(e).hide()
}
},
/**
@ -81,17 +111,17 @@ const commonMixins = {
* @params function callback function
*/
get: function (url, values, callback) {
let self = this;
self.loading++;
let self = this
self.loading++
axios.get(url, { params: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
self.loading--
}
});
})
},
/**
@ -102,17 +132,17 @@ const commonMixins = {
* @params function callback function
*/
post: function (url, data, callback) {
let self = this;
self.loading++;
let self = this
self.loading++
axios.post(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
self.loading--
}
});
})
},
/**
@ -123,17 +153,17 @@ const commonMixins = {
* @params function callback function
*/
delete: function (url, data, callback) {
let self = this;
self.loading++;
let self = this
self.loading++
axios.delete(url, { data: data })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
self.loading--
}
});
})
},
/**
@ -144,73 +174,73 @@ const commonMixins = {
* @params function callback function
*/
put: function (url, data, callback) {
let self = this;
self.loading++;
let self = this
self.loading++
axios.put(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
self.loading--
}
});
})
},
allAttachments: function (message) {
let a = [];
let a = []
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
a.push(message.Attachments[i])
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
a.push(message.OtherParts[i])
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
a.push(message.Inline[i])
}
return a.length ? a : false;
return a.length ? a : false
},
isImage(a) {
return a.ContentType.match(/^image\//);
return a.ContentType.match(/^image\//)
},
attachmentIcon: function (a) {
let ext = a.FileName.split('.').pop().toLowerCase();
let ext = a.FileName.split('.').pop().toLowerCase()
if (a.ContentType.match(/^image\//)) {
return 'bi-file-image-fill';
return 'bi-file-image-fill'
}
if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') {
return 'bi-file-pdf-fill';
return 'bi-file-pdf-fill'
}
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) {
return 'bi-file-word-fill';
return 'bi-file-word-fill'
}
if (['xls', 'xlsx', 'ods'].includes(ext)) {
return 'bi-file-spreadsheet-fill';
return 'bi-file-spreadsheet-fill'
}
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) {
return 'bi-file-slides-fill';
return 'bi-file-slides-fill'
}
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
return 'bi-file-zip-fill';
return 'bi-file-zip-fill'
}
if (a.ContentType.match(/^audio\//)) {
return 'bi-file-music-fill';
return 'bi-file-music-fill'
}
if (a.ContentType.match(/^video\//)) {
return 'bi-file-play-fill';
return 'bi-file-play-fill'
}
if (a.ContentType.match(/\/calendar$/)) {
return 'bi-file-check-fill';
return 'bi-file-check-fill'
}
if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) {
return 'bi-file-text-fill';
return 'bi-file-text-fill'
}
return 'bi-file-arrow-down-fill';
return 'bi-file-arrow-down-fill'
},
// Returns a hex color based on a string.
@ -223,18 +253,5 @@ const commonMixins = {
return this.tagColorCache[s]
},
toggleTagColors: function () {
if (this.showTagColors) {
localStorage.removeItem('showTagsColors')
this.showTagColors = false
} else {
localStorage.setItem('showTagsColors', '1')
this.showTagColors = true
}
}
}
}
export default commonMixins;

View File

@ -0,0 +1,90 @@
import CommonMixins from './CommonMixins.js'
import { mailbox } from '../stores/mailbox.js'
import { pagination } from '../stores/pagination.js'
export default {
mixins: [CommonMixins],
data() {
return {
apiURI: false,
pagination,
mailbox,
}
},
watch: {
'mailbox.refresh': function (v) {
if (v) {
// trigger a refresh
this.loadMessages()
}
mailbox.refresh = false
}
},
methods: {
reloadMailbox: function () {
pagination.start = 0
this.loadMessages()
},
loadMessages: function () {
if (!this.apiURI) {
alert('apiURL not set!')
return
}
let self = this
let params = {}
mailbox.selected = []
params['limit'] = pagination.limit
if (pagination.start > 0) {
params['start'] = pagination.start
}
self.get(this.apiURI, params, function (response) {
mailbox.total = response.data.total // all messages
mailbox.unread = response.data.unread // all unread messages
mailbox.tags = response.data.tags // all tags
mailbox.messages = response.data.messages // current messages
mailbox.count = response.data.messages_count // total results for this mailbox/search
// ensure the pagination remains consistent
pagination.start = response.data.start
if (response.data.count == 0 && response.data.start > 0) {
pagination.start = 0
return self.loadMessages()
}
if (mailbox.lastMessage) {
window.setTimeout(() => {
let m = document.getElementById(mailbox.lastMessage)
if (m) {
m.focus()
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
m.scrollIntoView({ block: 'center' })
} else {
let mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}
}
mailbox.lastMessage = false
}, 50)
} else if (!window.scrollInPlace) {
let mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}
}
window.scrollInPlace = false
})
},
}
}

View File

@ -0,0 +1,37 @@
import { createRouter, createWebHistory } from 'vue-router'
import MailboxView from '../views/MailboxView.vue'
import MessageView from '../views/MessageView.vue'
import NotFoundView from '../views/NotFoundView.vue'
import SearchView from '../views/SearchView.vue'
let d = document.getElementById('app')
let webroot = '/'
if (d) {
webroot = d.dataset.webroot
}
// paths are relative to webroot
const router = createRouter({
history: createWebHistory(webroot),
routes: [
{
path: '/',
component: MailboxView
},
{
path: '/search',
component: SearchView
},
{
path: '/view/:id',
component: MessageView
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFoundView
}
]
})
export default router

View File

@ -0,0 +1,59 @@
// State Management
import { reactive, watch } from 'vue'
import Tinycon from 'tinycon'
Tinycon.setOptions({
height: 11,
background: '#dd0000',
fallback: false,
font: '9px arial',
})
// global mailbox info
export const mailbox = reactive({
total: 0, // total number of messages in database
unread: 0, // total unread messages in database
count: 0, // total in mailbox or search
messages: [], // current messages
tags: [], // all tags
showTagColors: false, // show tag colors?
selected: [], // currently selected
connected: false, // websocket connection
searching: false, // current search, false for none
refresh: false, // to listen from MessagesMixin
notificationsSupported: false,
notificationsEnabled: false,
appInfo: {}, // application information
uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling
})
watch(
() => mailbox.unread,
(v) => {
if (v == 0) {
Tinycon.reset()
} else {
Tinycon.setBubble(v)
}
}
)
watch(
() => mailbox.count,
(v) => {
mailbox.selected = []
}
)
watch(
() => mailbox.showTagColors,
(v) => {
if (v) {
localStorage.setItem('showTagsColors', '1')
} else {
localStorage.removeItem('showTagsColors')
}
}
)

View File

@ -0,0 +1,8 @@
import { reactive } from 'vue'
export const pagination = reactive({
start: 0, // pagination offset
limit: 50, // per page
total: 0, // total results of current view / filter
count: 0, // number of messages currently displayed
})

View File

@ -1,116 +0,0 @@
<script>
import Tags from "bootstrap5-tags"
import commonMixins from '../mixins.js'
export default {
props: {
message: Object,
uiConfig: Object,
releaseAddresses: Array
},
data() {
return {
addresses: []
}
},
mixins: [commonMixins],
mounted() {
this.addresses = JSON.parse(JSON.stringify(this.releaseAddresses))
this.$nextTick(function () {
Tags.init("select[multiple]")
})
},
methods: {
releaseMessage: function () {
let self = this
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
return false
}
let data = {
to: self.addresses
}
self.post('api/v1/message/' + self.message.ID + '/release', data, function (response) {
self.modal("ReleaseModal").hide()
})
}, 100)
}
}
}
</script>
<template>
<div class="modal-dialog modal-lg" v-if="message">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="AppInfoModalLabel">Release email</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h6>Send this message to one or more addresses specified below.</h6>
<div class="row">
<label class="col-sm-2 col-form-label text-body-secondary">From</label>
<div class="col-sm-10">
<input type="text" aria-label="From address" readonly class="form-control-plaintext"
:value="message.From.Address">
</div>
</div>
<div class="row">
<label class=" col-sm-2 col-form-label text-body-secondary">Subject</label>
<div class="col-sm-10">
<input type="text" aria-label="Subject" readonly class="form-control-plaintext"
:value="message.Subject">
</div>
</div>
<div class="row mb-3">
<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-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>
<!-- you need at least one option with the placeholder -->
<option v-for="t in releaseAddresses" :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid email address</div>
</div>
</div>
<div class="form-text text-center" v-if="uiConfig.MessageRelay.RecipientAllowlist != ''">
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
<br class="d-none d-md-inline">
Configured allowlist: <b>{{ uiConfig.MessageRelay.RecipientAllowlist }}</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="uiConfig.MessageRelay.ReturnPath != ''">{{ uiConfig.MessageRelay.ReturnPath }}</b>
<b v-else>{{ message.ReturnPath }}</b>.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="!addresses.length"
v-on:click="releaseMessage">Release</button>
</div>
</div>
</div>
<div id="loading" v-if="loading">
<div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</template>

View File

@ -1,28 +0,0 @@
<script>
import commonMixins from '../mixins.js'
export default {
props: {
message: Object
},
mixins: [commonMixins]
}
</script>
<template>
<div class="card mt-4">
<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>
</template>

View File

@ -1,44 +0,0 @@
<script>
import { Toast } from 'bootstrap'
export default {
props: {
message: Object
},
mounted() {
let self = this
let el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.$emit("clearMessageToast")
})
let b = Toast.getOrCreateInstance(el)
b.show()
}
}
}
</script>
<template>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="messageToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-envelope-exclamation-fill me-2"></i>
<strong class="me-auto"><a :href="'#' + message.ID">New message</a></strong>
<small class="text-body-secondary">now</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div>
<a :href="'#' + message.ID" class="d-block text-truncate text-body-secondary">
<template v-if="message.Subject != ''">{{ message.Subject }}</template>
<template v-else>[ no subject ]</template>
</a>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,123 +0,0 @@
<script>
export default {
data() {
return {
theme: 'auto',
icon: '#circle-half',
icons: {
'auto': '#circle-half',
'light': '#sun-fill',
'dark': '#moon-stars-fill'
}
}
},
mounted() {
this.setTheme(this.getPreferredTheme())
},
methods: {
getStoredTheme: function () {
let theme = localStorage.getItem('theme')
if (!theme) {
theme = 'auto'
}
return theme
},
setStoredTheme: function (theme) {
localStorage.setItem('theme', theme)
this.setTheme(theme)
},
getPreferredTheme: function () {
const storedTheme = this.getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
},
setTheme: function (theme) {
this.icon = this.icons[theme]
this.theme = theme
if (
theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
}
}
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="bootstrap" viewBox="0 0 512 408" fill="currentcolor">
<path
d="M106.342 0c-29.214 0-50.827 25.58-49.86 53.32.927 26.647-.278 61.165-8.966 89.31C38.802 170.862 24.07 188.707 0 191v26c24.069 2.293 38.802 20.138 47.516 48.37 8.688 28.145 9.893 62.663 8.965 89.311C55.515 382.42 77.128 408 106.342 408h299.353c29.214 0 50.827-25.58 49.861-53.319-.928-26.648.277-61.166 8.964-89.311 8.715-28.232 23.411-46.077 47.48-48.37v-26c-24.069-2.293-38.765-20.138-47.48-48.37-8.687-28.145-9.892-62.663-8.964-89.31C456.522 25.58 434.909 0 405.695 0H106.342zm236.559 251.102c0 38.197-28.501 61.355-75.798 61.355h-87.202a2 2 0 01-2-2v-213a2 2 0 012-2h86.74c39.439 0 65.322 21.354 65.322 54.138 0 23.008-17.409 43.61-39.594 47.219v1.203c30.196 3.309 50.532 24.212 50.532 53.085zm-84.58-128.125h-45.91v64.814h38.669c29.888 0 46.373-12.03 46.373-33.535 0-20.151-14.174-31.279-39.132-31.279zm-45.91 90.53v71.431h47.605c31.12 0 47.605-12.482 47.605-35.941 0-23.46-16.947-35.49-49.608-35.49h-45.602z" />
</symbol>
<symbol id="check2" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</symbol>
<symbol id="circle-half" viewBox="0 0 16 16" fill="currentcolor">
<path d="M8 15A7 7 0 1 0 8 1v14zm0 1A8 8 0 1 1 8 0a8 8 0 0 1 0 16z" />
</symbol>
<symbol id="moon-stars-fill" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z" />
<path
d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a1.156 1.156 0 0 0-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z" />
</symbol>
<symbol id="sun-fill" viewBox="0 0 16 16" fill="currentcolor">
<path
d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" />
</symbol>
</svg>
<div class="dropdown bd-mode-toggle float-end me-2 d-inline-block">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" aria-expanded="false"
title="Toggle theme" data-bs-toggle="dropdown" aria-label="Toggle theme">
<svg class="bi my-1 theme-icon-active" width="1em" height="1em">
<use :href="icon"></use>
</svg>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'light' ? 'active' : ''" @click="setStoredTheme('light')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#sun-fill"></use>
</svg>
Light
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'dark' ? 'active' : ''" @click="setStoredTheme('dark')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#moon-stars-fill"></use>
</svg>
Dark
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
:class="theme == 'auto' ? 'active' : ''" @click="setStoredTheme('auto')">
<svg class="bi me-2 opacity-50 theme-icon" width="1em" height="1em">
<use href="#circle-half"></use>
</svg>
Auto
</button>
</li>
</ul>
</div>
</template>

View File

@ -0,0 +1,94 @@
<script>
import AboutMailpit from '../components/AboutMailpit.vue'
import AjaxLoader from '../components/AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import ListMessages from '../components/ListMessages.vue'
import MessagesMixins from '../mixins/MessagesMixins'
import NavMailbox from '../components/NavMailbox.vue'
import NavTags from '../components/NavTags.vue'
import Pagination from '../components/Pagination.vue'
import SearchForm from '../components/SearchForm.vue'
import { mailbox } from '../stores/mailbox'
export default {
mixins: [CommonMixins, MessagesMixins],
components: {
AboutMailpit,
AjaxLoader,
ListMessages,
NavMailbox,
NavTags,
Pagination,
SearchForm,
},
data() {
return {
mailbox,
}
},
mounted() {
mailbox.searching = false
this.apiURI = this.resolve(`/api/v1/messages`)
this.loadMessages()
},
}
</script>
<template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
<div class="col-xl-2 col-md-3 col-auto pe-0">
<RouterLink to="/" class="navbar-brand text-white me-0" @click="reloadMailbox">
<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">
<SearchForm />
</div>
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-md-0">
<div class="float-start d-md-none">
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvas" aria-controls="offcanvas">
<i class="bi bi-list"></i>
</button>
</div>
<Pagination @loadMessages="loadMessages" :total="mailbox.total" />
</div>
</div>
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
aria-labelledby="offcanvasLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5>
<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>
</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 />
<AboutMailpit />
</div>
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page">
<ListMessages />
</div>
</div>
</div>
<NavMailbox @loadMessages="loadMessages" modals />
<AboutMailpit modals />
<AjaxLoader :loading="loading" />
</template>

View File

@ -0,0 +1,323 @@
<script>
import AboutMailpit from '../components/AboutMailpit.vue'
import AjaxLoader from '../components/AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import Message from '../components/message/Message.vue'
import Release from '../components/message/Release.vue'
import Screenshot from '../components/message/Screenshot.vue'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins],
components: {
AboutMailpit,
AjaxLoader,
Message,
Screenshot,
Release,
},
data() {
return {
mailbox,
pagination,
message: false,
prevLink: false,
nextLink: false,
}
},
watch: {
$route(to, from) {
this.loadMessage()
}
},
mounted() {
this.loadMessage()
},
methods: {
loadMessage: function () {
let self = this
this.message = false
let uri = self.resolve('/api/v1/message/' + this.$route.params.id)
self.get(uri, false, function (response) {
let d = response.data
if (self.wasUnread(d.ID)) {
mailbox.unread--
}
// replace inline images embedded as inline attachments
if (d.HTML && d.Inline) {
for (let i in d.Inline) {
let a = d.Inline[i]
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
}
}
// replace inline images embedded as regular attachments
if (d.HTML && d.Attachments) {
for (let i in d.Attachments) {
let a = d.Attachments[i]
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
}
}
self.message = d
self.detectPrevNext()
})
},
// try detect whether this message was unread based on messages listing
wasUnread: function (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
}
}
},
detectPrevNext: function () {
// 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
}
}
},
downloadMessageBody: function (str, ext) {
let dl = document.createElement('a')
dl.href = "data:text/plain," + encodeURIComponent(str)
dl.target = '_blank'
dl.download = this.message.ID + '.' + ext
dl.click()
},
screenshotMessageHTML: function () {
this.$refs.ScreenshotRef.initScreenshot()
},
// mark current message as read
markUnread: function () {
let self = this
if (!self.message) {
return false
}
let uri = self.resolve('/api/v1/messages')
self.put(uri, { 'read': false, 'ids': [self.message.ID] }, function (response) {
self.goBack()
})
},
deleteMessage: function () {
let self = this
let ids = [self.message.ID]
let uri = self.resolve('/api/v1/messages')
self.delete(uri, { 'ids': ids }, function () {
self.goBack()
})
},
goBack: function () {
mailbox.lastMessage = this.$route.params.id
if (mailbox.searching) {
this.$router.push('/search?q=' + encodeURIComponent(mailbox.searching))
} else {
this.$router.push('/')
}
},
initReleaseModal: function () {
let self = this
self.modal('ReleaseModal').show()
window.setTimeout(function () {
window.setTimeout(function () {
// delay to allow elements to load / focus
self.$refs.ReleaseRef.initTags()
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
}, 500)
}, 300)
},
}
}
</script>
<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">
<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">
<button @click="goBack()" class="btn btn-outline-light me-4 d-md-none" title="Return to messages">
<i class="bi bi-arrow-return-left"></i>
</button>
<button class="btn btn-outline-light me-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>
<button class="btn btn-outline-light me-2" title="Release message"
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled" 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-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>
<div class="col-auto col-lg-4 col-xl-4 text-end">
<div class="dropdown d-inline-block" id="DownloadBtn">
<button type="button" class="btn btn-outline-light dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-file-arrow-down-fill"></i>
<span class="d-none d-md-inline ms-1">Download</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a :href="resolve('/api/v1/message/' + message.ID + '/raw?dl=1')" class="dropdown-item"
title="Message source including headers, body and attachments">
Raw message
</a>
</li>
<li v-if="message.HTML">
<button v-on:click="downloadMessageBody(message.HTML, 'html')" class="dropdown-item">
HTML body
</button>
</li>
<li v-if="message.HTML">
<button class="dropdown-item" @click="screenshotMessageHTML()">
HTML screenshot
</button>
</li>
<li v-if="message.Text">
<button v-on:click="downloadMessageBody(message.Text, 'txt')" class="dropdown-item">
Text body
</button>
</li>
<template v-if="allAttachments(message).length">
<li>
<hr class="dropdown-divider">
</li>
<li>
<h6 class="dropdown-header">
Attachment<template v-if="allAttachments(message).length > 1">s</template>
</h6>
</li>
<li v-for="part in allAttachments(message)">
<RouterLink :to="'/api/v1/message/' + message.ID + '/part/' + part.PartID"
class="row m-0 dropdown-item d-flex" target="_blank"
:title="part.FileName != '' ? part.FileName : '[ unknown ]'" style="min-width: 350px">
<div class="col-auto p-0 pe-1">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="col text-truncate p-0 pe-1">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</div>
<div class="col-auto text-muted small p-0">
{{ getFileSize(part.Size) }}
</div>
</RouterLink>
</li>
</template>
</ul>
</div>
<RouterLink :to="'/view/' + prevLink" class="btn btn-outline-light ms-2 me-1"
:class="prevLink ? '' : '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'">
<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="list-group my-2">
<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="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
v-if="mailbox.unread">
{{ formatNumber(mailbox.unread) }}
</span>
</button>
</div>
<div class="card mt-4">
<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>
<AboutMailpit />
</div>
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page">
<Message v-if="message" :key="message.ID" :message="message" />
</div>
</div>
</div>
<AboutMailpit modals />
<AjaxLoader :loading="loading" />
<Release v-if="message" ref="ReleaseRef" :message="message" />
<Screenshot v-if="message" ref="ScreenshotRef" :message="message" />
</template>

View File

@ -0,0 +1,29 @@
<script>
import AboutMailpit from '../components/AboutMailpit.vue'
import CommonMixins from '../mixins/CommonMixins'
export default {
mixins: [CommonMixins],
components: {
AboutMailpit,
},
}
</script>
<template>
<div class="h-100 bg-primary d-flex align-items-center justify-content-center my-2 text-white">
<div class="d-block text-center">
<RouterLink to="/" class="text-white">
<img :src="resolve('/mailpit.svg')" alt="Mailpit" style="max-width:80%; width: 100px;">
<p class="h2 my-3">Page not found</p>
<p>Click here to continue</p>
</RouterLink>
</div>
<div class="d-none">
<AboutMailpit />
</div>
</div>
</template>

View File

@ -0,0 +1,122 @@
<script>
import AboutMailpit from '../components/AboutMailpit.vue'
import AjaxLoader from '../components/AjaxLoader.vue'
import CommonMixins from '../mixins/CommonMixins'
import ListMessages from '../components/ListMessages.vue'
import MessagesMixins from '../mixins/MessagesMixins'
import NavSearch from '../components/NavSearch.vue'
import NavTags from '../components/NavTags.vue'
import Pagination from '../components/Pagination.vue'
import SearchForm from '../components/SearchForm.vue'
import { mailbox } from '../stores/mailbox'
import { pagination } from '../stores/pagination'
export default {
mixins: [CommonMixins, MessagesMixins],
components: {
AboutMailpit,
AjaxLoader,
ListMessages,
NavSearch,
NavTags,
Pagination,
SearchForm,
},
data() {
return {
mailbox,
pagination,
}
},
watch: {
$route(to, from) {
this.doSearch(true)
}
},
mounted() {
mailbox.searching = this.getSearch()
this.doSearch(false)
},
methods: {
doSearch: function (resetPagination) {
let s = this.getSearch()
if (!s) {
mailbox.searching = false
this.$router.push('/')
return
}
mailbox.searching = s
if (resetPagination) {
pagination.start = 0
}
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.loadMessages()
}
}
}
</script>
<template>
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
<div class="col-xl-2 col-md-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">
<SearchForm />
</div>
<div class="col-12 col-md-auto col-lg-4 col-xl-4 text-end mt-2 mt-lg-0">
<div class="float-start d-md-none">
<button class="btn btn-outline-light me-2" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvas" aria-controls="offcanvas">
<i class="bi bi-list"></i>
</button>
</div>
<Pagination @loadMessages="loadMessages" :total="mailbox.count" />
</div>
</div>
<div class="offcanvas-md offcanvas-start d-md-none" data-bs-scroll="true" tabindex="-1" id="offcanvas"
aria-labelledby="offcanvasLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasLabel">Mailpit</h5>
<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 />
<AboutMailpit />
</div>
</div>
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative"
style="overflow-y: auto; overflow-x: hidden;">
<NavSearch @loadMessages="loadMessages" />
<NavTags />
<AboutMailpit />
</div>
<div class="col-lg-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
<div class="mh-100" style="overflow-y: auto;" id="message-page">
<ListMessages />
</div>
</div>
</div>
<NavSearch @loadMessages="loadMessages" modals />
<AboutMailpit modals />
<AjaxLoader :loading="loading" />
</template>

View File

@ -532,9 +532,64 @@
"$ref": "#/responses/ErrorResponse"
}
}
},
"delete": {
"description": "Deletes messages matching a search.",
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"tags": [
"messages"
],
"summary": "Delete messages by search",
"operationId": "MessagesSummary",
"parameters": [
{
"type": "string",
"description": "Search query",
"name": "query",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/OKResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
}
},
"/api/v1/tags": {
"get": {
"description": "Returns a JSON array of all unique message tags.",
"produces": [
"application/json"
],
"schemes": [
"http",
"https"
],
"tags": [
"tags"
],
"summary": "Get all current tags",
"operationId": "SetTags",
"responses": {
"200": {
"$ref": "#/responses/ArrayResponse"
},
"default": {
"$ref": "#/responses/ErrorResponse"
}
}
},
"put": {
"description": "To remove all tags from a message, pass an empty tags array.",
"consumes": [
@ -964,10 +1019,6 @@
"description": "Message ID",
"type": "string"
},
"Read": {
"description": "Read status",
"type": "boolean"
},
"ReplyTo": {
"description": "ReplyTo addresses",
"type": "array",
@ -1234,6 +1285,15 @@
}
},
"responses": {
"ArrayResponse": {
"description": "Plain JSON array response",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"BinaryResponse": {
"description": "Binary data response inherits the attachment's content type"
},

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="favicon.svg">
<title>Mailpit</title>
<link rel=stylesheet href="dist/app.css">
</head>
<body class="h-100">
<div class="container-fluid h-100 d-flex flex-column" id="app">
<noscript>You require JavaScript to use this app.</noscript>
</div>
<script src="dist/app.js"></script>
</body>
</html>

View File

@ -79,7 +79,7 @@ func Broadcast(t string, msg interface{}) {
b, err := json.Marshal(w)
if err != nil {
logger.Log().Errorf("[http] broadcast received invalid data: %s", err)
logger.Log().Errorf("[websocket] broadcast received invalid data: %s", err)
}
go func() { MessageHub.Broadcast <- b }()

View File

@ -25,7 +25,6 @@ import (
"github.com/jhillyerd/enmime"
"github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf"
"github.com/mattn/go-shellwords"
uuid "github.com/satori/go.uuid"
// sqlite (native) - https://gitlab.com/cznic/sqlite
@ -317,6 +316,8 @@ func Store(body []byte) (string, error) {
dbLastAction = time.Now()
BroadcastMailboxStats()
return id, nil
}
@ -367,9 +368,6 @@ func List(start, limit int) ([]MessageSummary, error) {
em.Read = read == 1
results = append(results, em)
// logger.PrettyPrint(em)
}); err != nil {
return results, err
}
@ -379,96 +377,6 @@ func List(start, limit int) ([]MessageSummary, error) {
return results, nil
}
// Search will search a mailbox for search terms.
// 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 string, start, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
s := strings.ToLower(search)
// add another quote if missing closing quote
quotes := strings.Count(s, `"`)
if quotes%2 != 0 {
s += `"`
}
p := shellwords.NewParser()
args, err := p.Parse(s)
if err != nil {
return results, nrResults, errors.New("Your search contains invalid characters")
}
// generate the SQL based on arguments
q := searchParser(args)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var read int
var ignore string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
logger.Log().Error(err)
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
allResults = append(allResults, em)
}); err != nil {
return results, nrResults, err
}
dbLastAction = time.Now()
nrResults = len(allResults)
if nrResults > start {
end := nrResults
if nrResults >= start+limit {
end = start + limit
}
results = allResults[start:end]
}
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
return results, nrResults, err
}
// GetMessage returns a Message generated from the mailbox_data collection.
// If the message lacks a date header, then the received datetime is used.
func GetMessage(id string) (*Message, error) {
@ -525,7 +433,6 @@ func GetMessage(id string) (*Message, error) {
obj := Message{
ID: id,
MessageID: messageID,
Read: true,
From: from,
Date: date,
To: addressToSlice(env, "To"),
@ -651,6 +558,8 @@ func MarkRead(id string) error {
logger.Log().Debugf("[db] marked message %s as read", id)
}
BroadcastMailboxStats()
return err
}
@ -672,6 +581,8 @@ func MarkAllRead() error {
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
@ -695,6 +606,8 @@ func MarkAllUnread() error {
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed)
BroadcastMailboxStats()
dbLastAction = time.Now()
return nil
@ -717,6 +630,8 @@ func MarkUnread(id string) error {
dbLastAction = time.Now()
BroadcastMailboxStats()
return err
}
@ -751,6 +666,8 @@ func DeleteOneMessage(id string) error {
dbLastAction = time.Now()
dbDataDeleted = true
BroadcastMailboxStats()
return err
}
@ -799,19 +716,13 @@ func DeleteAllMessages() error {
dbDataDeleted = false
websockets.Broadcast("prune", nil)
BroadcastMailboxStats()
return err
}
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet() MailboxStats {
var (
total = CountTotal()
unread = CountUnread()
)
dbLastAction = time.Now()
// GetAllTags returns all used tags
func GetAllTags() []string {
q := sqlf.From("mailbox").
Select(`DISTINCT Tags`).
Where("Tags != ?", "[]")
@ -844,6 +755,19 @@ func StatsGet() MailboxStats {
sort.Strings(tags)
return tags
}
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet() MailboxStats {
var (
total = CountTotal()
unread = CountUnread()
tags = GetAllTags()
)
dbLastAction = time.Now()
return MailboxStats{
Total: total,
Unread: unread,

35
storage/notifications.go Normal file
View File

@ -0,0 +1,35 @@
package storage
import (
"time"
"github.com/axllent/mailpit/server/websockets"
)
var bcStatsDelay = false
// BroadcastMailboxStats broadcasts the total number of messages
// displayed to the web UI, as well as the total unread messages.
// The lookup is very fast (< 10ms / 100k messages under load).
// Rate limited to 4x per second.
func BroadcastMailboxStats() {
if bcStatsDelay {
return
}
bcStatsDelay = true
go func() {
time.Sleep(250 * time.Millisecond)
bcStatsDelay = false
b := struct {
Total int
Unread int
}{
Total: CountTotal(),
Unread: CountUnread(),
}
websockets.Broadcast("stats", b)
}()
}

View File

@ -1,14 +1,193 @@
package storage
import (
"context"
"database/sql"
"encoding/json"
"regexp"
"strings"
"time"
"github.com/axllent/mailpit/utils/logger"
"github.com/axllent/mailpit/utils/tools"
"github.com/leporo/sqlf"
)
// Search will search a mailbox for search terms.
// 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 string, start, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{}
allResults := []MessageSummary{}
tsStart := time.Now()
nrResults := 0
if limit < 0 {
limit = 50
}
q := searchParser(search)
var err error
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var read int
var ignore string
em := MessageSummary{}
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(metadata), &em); err != nil {
logger.Log().Error(err)
return
}
if err := json.Unmarshal([]byte(tags), &em.Tags); err != nil {
logger.Log().Error(err)
return
}
em.Created = time.UnixMilli(created)
em.ID = id
em.MessageID = messageID
em.Subject = subject
em.Size = size
em.Attachments = attachments
em.Read = read == 1
allResults = append(allResults, em)
}); err != nil {
return results, nrResults, err
}
dbLastAction = time.Now()
nrResults = len(allResults)
if nrResults > start {
end := nrResults
if nrResults >= start+limit {
end = start + limit
}
results = allResults[start:end]
}
elapsed := time.Since(tsStart)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
return results, nrResults, err
}
// DeleteSearch will delete all messages for search terms.
// 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 DeleteSearch(search string) error {
q := searchParser(search)
ids := []string{}
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var created int64
var id string
var messageID string
var subject string
var metadata string
var size int
var attachments int
var tags string
var read int
var ignore string
if err := row.Scan(&created, &id, &messageID, &subject, &metadata, &size, &attachments, &read, &tags, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
ids = append(ids, id)
}); err != nil {
return err
}
if len(ids) > 0 {
total := len(ids)
// split ids into chunks
var chunks [][]string
if total > 1000 {
chunkSize := 1000
chunks = make([][]string, 0, (len(ids)+chunkSize-1)/chunkSize)
for chunkSize < len(ids) {
ids, chunks = ids[chunkSize:], append(chunks, ids[0:chunkSize:chunkSize])
}
} else {
chunks = append(chunks, ids)
}
// begin a transaction to ensure both the message
// and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer tx.Rollback()
for _, ids := range chunks {
delIDs := make([]interface{}, len(ids))
for i, id := range ids {
delIDs[i] = id
}
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
_, err = tx.Exec(sqlDelete1, delIDs...)
if err != nil {
return err
}
sqlDelete2 := `DELETE FROM mailbox_data WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
_, err = tx.Exec(sqlDelete2, delIDs...)
if err != nil {
return err
}
}
err = tx.Commit()
if err == nil {
logger.Log().Debugf("[db] deleted %d messages matching %s", total, search)
}
dbLastAction = time.Now()
dbDataDeleted = true
BroadcastMailboxStats()
}
return nil
}
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchParser(args []string) *sqlf.Stmt {
func searchParser(searchString string) *sqlf.Stmt {
searchString = strings.ToLower(searchString)
// group strings with quotes as a single argument and remove quotes
args := tools.ArgsParser(searchString)
q := sqlf.From("mailbox").
Select(`Created, ID, MessageID, Subject, Metadata, Size, Attachments, Read, Tags,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
@ -71,7 +250,7 @@ func searchParser(args []string) *sqlf.Stmt {
}
}
} else if strings.HasPrefix(w, "subject:") {
w = cleanString(w[8:])
w = w[8:]
if w != "" {
if exclude {
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
@ -109,6 +288,12 @@ func searchParser(args []string) *sqlf.Stmt {
} else {
q.Where("Read = 0")
}
} else if w == "is:tagged" {
if exclude {
q.Where("Tags = ?", "[]")
} else {
q.Where("Tags != ?", "[]")
}
} else if w == "has:attachment" || w == "has:attachments" {
if exclude {
q.Where("Attachments = 0")

View File

@ -15,8 +15,6 @@ type Message struct {
ID string
// Message ID
MessageID string
// Read status
Read bool
// From address
From *mail.Address
// To addresses

View File

@ -61,7 +61,7 @@ func createSearchText(env *enmime.Envelope) string {
// CleanString removes unwanted characters from stored search text and search queries
func cleanString(str string) string {
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;)`)
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|\,|;|\(|\))`)
str = re.ReplaceAllString(str, " ")
// remove duplicate whitespace and trim
@ -183,3 +183,42 @@ func inArray(k string, arr []string) bool {
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")
}
// Escape certain characters in search phrases
func escSearch(str string) string {
dest := make([]byte, 0, 2*len(str))
var escape byte
for i := 0; i < len(str); i++ {
c := str[i]
escape = 0
switch c {
case 0: /* Must be escaped for 'mysql' */
escape = '0'
break
case '\n': /* Must be escaped for logs */
escape = 'n'
break
case '\r':
escape = 'r'
break
case '\\':
escape = '\\'
break
case '\'':
escape = '\''
break
case '\032': //十进制26,八进制32,十六进制1a, /* This gives problems on Win32 */
escape = 'Z'
}
if escape != 0 {
dest = append(dest, '\\', escape)
} else {
dest = append(dest, c)
}
}
return string(dest)
}

32
utils/tools/args.go Normal file
View File

@ -0,0 +1,32 @@
package tools
import "strings"
// ArgsParser will split a string by new words and quotes phrases
func ArgsParser(s string) []string {
args := []string{}
sb := &strings.Builder{}
quoted := false
for _, r := range s {
if r == '"' {
quoted = !quoted
sb.WriteRune(r) // keep '"' otherwise comment this line
} else if !quoted && r == ' ' {
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
if v != "" {
args = append(args, v)
}
sb.Reset()
} else {
sb.WriteRune(r)
}
}
if sb.Len() > 0 {
v := strings.TrimSpace(strings.ReplaceAll(sb.String(), "\"", ""))
if v != "" {
args = append(args, v)
}
}
return args
}