mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-10 10:57:00 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaa873ed68 | ||
|
|
fb8b24cc28 | ||
|
|
7d55e20e85 | ||
|
|
e98109a238 | ||
|
|
3cec8bfab8 | ||
|
|
4f2324a367 | ||
|
|
ac60ed62ae | ||
|
|
65327b975b | ||
|
|
ba42cac2ad | ||
|
|
5fc025b1a5 | ||
|
|
48bef8d7ac | ||
|
|
37ea30fcdb | ||
|
|
8f1b804b2a | ||
|
|
f8a6bd7d5e | ||
|
|
047c658157 | ||
|
|
a060abd5fe | ||
|
|
a21808df65 | ||
|
|
1e4fc9f003 | ||
|
|
3fdbcaff8a | ||
|
|
71820dc124 | ||
|
|
81e98d1376 | ||
|
|
27c36f52b2 | ||
|
|
325394876d | ||
|
|
5a54994a5d | ||
|
|
d48b5e8674 | ||
|
|
3f3da220cf | ||
|
|
9040e04edf | ||
|
|
6baf13b25b | ||
|
|
4716c18d5f | ||
|
|
22693f727f | ||
|
|
476843d9f3 | ||
|
|
a1cb0af639 | ||
|
|
54e0c32948 | ||
|
|
9670183d0f | ||
|
|
05da2a76f4 | ||
|
|
f16289078e | ||
|
|
5580967c78 | ||
|
|
eeb2c03424 | ||
|
|
0127b9a1f2 | ||
|
|
a078c318e8 | ||
|
|
9e881ea868 | ||
|
|
41c957b807 | ||
|
|
ea0b5f66f7 | ||
|
|
1f7a60452e | ||
|
|
14943324e8 | ||
|
|
b05c6fbf60 | ||
|
|
21a6f798d1 | ||
|
|
9014376e80 |
61
CHANGELOG.md
61
CHANGELOG.md
@@ -2,6 +2,67 @@
|
||||
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
## [v1.20.2]
|
||||
|
||||
### Feature
|
||||
- Web UI notifications of smtpd & POP3 errors ([#347](https://github.com/axllent/mailpit/issues/347))
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
- Add debug database storage logging
|
||||
- Add smtpd server logging in the CLI ([#347](https://github.com/axllent/mailpit/issues/347))
|
||||
|
||||
|
||||
## [v1.20.1]
|
||||
|
||||
### Chore
|
||||
- Shift inbox pagination to inbox component
|
||||
- Live load up to 100 new messages in sidebar ([#336](https://github.com/axllent/mailpit/issues/336))
|
||||
- Show icon attachment in new side navigation message listing ([#345](https://github.com/axllent/mailpit/issues/345))
|
||||
|
||||
### Fix
|
||||
- Correctly decode X-Tags message headers (RFC 2047) ([#344](https://github.com/axllent/mailpit/issues/344))
|
||||
|
||||
|
||||
## [v1.20.0]
|
||||
|
||||
### Feature
|
||||
- Add option to control message retention by age ([#338](https://github.com/axllent/mailpit/issues/338))
|
||||
- **UI:** List messages in side nav when viewing message for easy navigation ([#336](https://github.com/axllent/mailpit/issues/336))
|
||||
|
||||
### Chore
|
||||
- Update caniemail database
|
||||
- Update Go dependencies
|
||||
- Update node dependencies
|
||||
- Make internal tagging methods private
|
||||
|
||||
### Fix
|
||||
- Prevent potential JavaScript errors caused by race condition
|
||||
- Better regexp to detect tags in search
|
||||
- Prevent Vue race condition to initialize dayjs relativeTime plugin
|
||||
- **API:** Return `text/plain` header for message delete request
|
||||
|
||||
|
||||
## [v1.19.3]
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
- Display nicer noscript message when JavaScript is disabled
|
||||
|
||||
### Fix
|
||||
- **Security:** Prevent bypass of Contend Security Policy using stored XSS, and sanitize preview HTML data (DOMPurify)
|
||||
|
||||
|
||||
## [v1.19.2]
|
||||
|
||||
### Chore
|
||||
- Update Go dependencies
|
||||
|
||||
### Fix
|
||||
- Update Inbox "Delete All" count when new messages are detected ([#334](https://github.com/axllent/mailpit/issues/334))
|
||||
|
||||
|
||||
## [v1.19.1]
|
||||
|
||||
### Feature
|
||||
|
||||
19
SECURITY.md
Normal file
19
SECURITY.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Reporting security vulnerabilities
|
||||
|
||||
Your efforts to responsibly disclose your findings are appreciated.
|
||||
|
||||
** **Please do _not_ report security vulnerabilities through public GitHub issues.** **
|
||||
|
||||
If you believe you have found a **security vulnerability**, then please report it to security@axllent.org so
|
||||
your findings can be investigated, and if confirmed, fixed and released in a timely manner.
|
||||
|
||||
Your report should include:
|
||||
|
||||
- Mailpit version
|
||||
- A vulnerability description
|
||||
- Reproduction steps (if applicable)
|
||||
- Any other details you think are likely to be important
|
||||
|
||||
You should receive an initial acknowledgement within 24 hours in most cases, and will kept updated throughout the process.
|
||||
|
||||
With your consent, your contributions will be publicly acknowledged.
|
||||
@@ -85,6 +85,7 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
|
||||
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
rootCmd.Flags().StringVar(&config.MaxAge, "max-age", config.MaxAge, "Max age of messages in either (h)ours or (d)ays (eg: 3d)")
|
||||
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
|
||||
rootCmd.Flags().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
|
||||
rootCmd.Flags().StringVar(&logger.LogFile, "log-file", logger.LogFile, "Log output to file instead of stdout")
|
||||
@@ -179,6 +180,9 @@ func initConfigFromEnv() {
|
||||
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
|
||||
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
|
||||
}
|
||||
if len(os.Getenv("MP_MAX_AGE")) > 0 {
|
||||
config.MaxAge = os.Getenv("MP_MAX_AGE")
|
||||
}
|
||||
if getEnabledFromEnv("MP_USE_MESSAGE_DATES") {
|
||||
config.UseMessageDates = true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
@@ -31,15 +32,22 @@ var (
|
||||
|
||||
// TenantID is an optional prefix to be applied to all database tables,
|
||||
// allowing multiple isolated instances of Mailpit to share a database.
|
||||
TenantID = ""
|
||||
TenantID string
|
||||
|
||||
// Label to identify this Mailpit instance (optional).
|
||||
// This gets applied to web UI, SMTP and optional POP3 server.
|
||||
Label = ""
|
||||
Label string
|
||||
|
||||
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
|
||||
MaxMessages = 500
|
||||
|
||||
// MaxAge is the maximum age of messages (auto-pruned every hour).
|
||||
// Value can be either <int>h for hours or <int>d for days
|
||||
MaxAge string
|
||||
|
||||
// MaxAgeInHours is the maximum age of messages in hours, set with parseMaxAge() using MaxAge value
|
||||
MaxAgeInHours int
|
||||
|
||||
// UseMessageDates sets the Created date using the message date, not the delivered date
|
||||
UseMessageDates bool
|
||||
|
||||
@@ -205,6 +213,9 @@ func VerifyConfig() error {
|
||||
cssFontRestriction = "'self'"
|
||||
}
|
||||
|
||||
// The default Content Security Policy is updates on every application page load to replace script-src 'self'
|
||||
// with a random nonce ID to prevent XSS. This applies to the Mailpit app & API.
|
||||
// See server.middleWareFunc()
|
||||
ContentSecurityPolicy = fmt.Sprintf("default-src 'self'; script-src 'self'; style-src %s 'unsafe-inline'; frame-src 'self'; img-src * data: blob:; font-src %s data:; media-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';",
|
||||
cssFontRestriction, cssFontRestriction,
|
||||
)
|
||||
@@ -215,6 +226,10 @@ func VerifyConfig() error {
|
||||
|
||||
Label = tools.Normalize(Label)
|
||||
|
||||
if err := parseMaxAge(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
TenantID = tools.Normalize(TenantID)
|
||||
if TenantID != "" {
|
||||
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
|
||||
@@ -455,6 +470,39 @@ func VerifyConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the --max-age value (if set)
|
||||
func parseMaxAge() error {
|
||||
if MaxAge == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\d+(h|d)$`)
|
||||
if !re.MatchString(MaxAge) {
|
||||
return fmt.Errorf("max-age must be either <int>h for hours or <int>d for days: %s", MaxAge)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(MaxAge, "h") {
|
||||
hours, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "h"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
MaxAgeInHours = hours
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(strings.TrimSuffix(MaxAge, "d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Debugf("[db] auto-deleting messages older than %s", MaxAge)
|
||||
|
||||
MaxAgeInHours = days * 24
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the SMTPRelayConfigFile (if set)
|
||||
func parseRelayConfig(c string) error {
|
||||
if c == "" {
|
||||
|
||||
24
go.mod
24
go.mod
@@ -8,7 +8,7 @@ require (
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/axllent/semver v0.0.1
|
||||
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024
|
||||
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jhillyerd/enmime v1.2.0
|
||||
@@ -18,17 +18,17 @@ require (
|
||||
github.com/lithammer/shortuuid/v4 v4.0.0
|
||||
github.com/mhale/smtpd v0.8.3
|
||||
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
|
||||
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
|
||||
github.com/rqlite/gorqlite v0.0.0-20240808172217-12ae7d03ef19
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/tg123/go-htpasswd v1.2.2
|
||||
github.com/vanng822/go-premailer v1.21.0
|
||||
golang.org/x/net v0.27.0
|
||||
golang.org/x/text v0.16.0
|
||||
golang.org/x/time v0.5.0
|
||||
golang.org/x/net v0.28.0
|
||||
golang.org/x/text v0.17.0
|
||||
golang.org/x/time v0.6.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.30.2
|
||||
modernc.org/sqlite v1.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -44,7 +44,7 @@ require (
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -54,12 +54,12 @@ require (
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.25.0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/image v0.19.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
|
||||
modernc.org/libc v1.55.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
|
||||
modernc.org/libc v1.59.9 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
|
||||
68
go.sum
68
go.sum
@@ -23,8 +23,8 @@ github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024 h1:saBP362Qm7zDdDXqv61kI4rzhmLFq3Z1gx34xpl6cWE=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240626202925-2eda941fd024/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6 h1:ZPy+2XJ8u0bB3sNFi+I72gMEMS7MTg7aZCCXPOjV8iw=
|
||||
github.com/gomarkdown/markdown v0.0.0-20240730141124-034f12af3bf6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -63,8 +63,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mhale/smtpd v0.8.3 h1:8j8YNXajksoSLZja3HdwvYVZPuJSqAxFsib3adzRRt8=
|
||||
@@ -89,8 +89,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418 h1:gYUQqzapdN4PQF5j0zDFI9ANQVAVFoJivNp5bTZEZMo=
|
||||
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/rqlite/gorqlite v0.0.0-20240808172217-12ae7d03ef19 h1:uuWunw893WVwpSg4kNBuS6swgABwc+rwInVtwR5E3eM=
|
||||
github.com/rqlite/gorqlite v0.0.0-20240808172217-12ae7d03ef19/go.mod h1:xF/KoXmrRyahPfo5L7Szb5cAAUl53dMWBh9cMruGEZg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
@@ -126,14 +126,14 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
|
||||
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
|
||||
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.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -142,13 +142,13 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -161,8 +161,8 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -176,16 +176,16 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
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=
|
||||
@@ -197,16 +197,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/ccgo/v4 v4.20.7 h1:skrinQsjxWfvj6nbC3ztZPJy+NuwmB3hV9zX/pthNYQ=
|
||||
modernc.org/ccgo/v4 v4.20.7/go.mod h1:UOkI3JSG2zT4E2ioHlncSOZsXbuDCZLvPi3uMlZT5GY=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
|
||||
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.1 h1:2K/vMbMDGymj0CO4mcQybYW8SW3czB+u9rlghpMkTrI=
|
||||
modernc.org/libc v1.55.1/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
|
||||
modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.59.9 h1:k+nNDDakwipimgmJ1D9H466LhFeSkaPPycAs1OZiDmY=
|
||||
modernc.org/libc v1.59.9/go.mod h1:EY/egGEU7Ju66eU6SBqCNYaFUDuc4npICkMWnU5EE3A=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
@@ -215,8 +215,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.30.2 h1:IPVVkhLu5mMVnS1dQgh3h0SAACRWcVk7aoLP9Us3UCk=
|
||||
modernc.org/sqlite v1.30.2/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
|
||||
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
|
||||
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version":"1.0.4",
|
||||
"last_update_date":"2024-05-30 19:50:57 +0000",
|
||||
"last_update_date":"2024-07-29 15:27:49 +0000",
|
||||
"nicenames":{"family":{"gmail":"Gmail","outlook":"Outlook","yahoo":"Yahoo! Mail","apple-mail":"Apple Mail","aol":"AOL","thunderbird":"Mozilla Thunderbird","microsoft":"Microsoft","samsung-email":"Samsung Email","sfr":"SFR","orange":"Orange","protonmail":"ProtonMail","hey":"HEY","mail-ru":"Mail.ru","fastmail":"Fastmail","laposte":"LaPoste.net","t-online-de":"T-online.de","free-fr":"Free.fr","gmx":"GMX","web-de":"WEB.DE","ionos-1and1":"1&1","rainloop":"RainLoop","wp-pl":"WP.pl"},"platform":{"desktop-app":"Desktop","desktop-webmail":"Desktop Webmail","mobile-webmail":"Mobile Webmail","webmail":"Webmail","ios":"iOS","android":"Android","windows":"Windows","macos":"macOS","windows-mail":"Windows Mail","outlook-com":"Outlook.com"},"support":{"supported":"Supported","mitigated":"Partially supported","unsupported":"Not supported","unknown":"Support unknown","mixed":"Mixed support"},"category":{"html":"HTML","css":"CSS","image":"Image formats","others":"Others"}},
|
||||
"data":[
|
||||
{
|
||||
@@ -2990,9 +2990,9 @@
|
||||
"last_test_date":"2020-02-25",
|
||||
"test_url":"https://www.caniemail.com/tests/css-units.html",
|
||||
"test_results_url":"",
|
||||
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"},"mobile-webmail":{"2020-02":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"n","2024-04":"n"},"ios":{"2020-02":"y","2024-04":"n"},"android":{"2020-02":"y","2024-04":"n"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"web-de":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-08":"y"},"android":{"2022-08":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"13":"y"},"ios":{"13":"y"}},"gmail":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"},"mobile-webmail":{"2020-02":"y"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"n"},"macos":{"2016":"y","16.80":"y"},"outlook-com":{"2020-02":"y #1","2024-01":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y #1"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"n","2024-04":"n"},"ios":{"2020-02":"y","2024-04":"n"},"android":{"2020-02":"y","2024-04":"n"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"y #1"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"web-de":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-08":"y"},"android":{"2022-08":"y"}}},
|
||||
"notes":"",
|
||||
"notes_by_num":{}
|
||||
"notes_by_num":{"1":"The HTML of the email message is embedded directly on the webmail (not in an <iframe>) and may not fill the full viewport's width. In this case, the vw values are relevant to the viewport (browser window) not the email message."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -3931,12 +3931,12 @@
|
||||
"category":"html",
|
||||
"tags":["accessibility","performance"],
|
||||
"keywords":"picture, responsive image",
|
||||
"last_test_date":"2019-05-29",
|
||||
"last_test_date":"2024-04-15",
|
||||
"test_url":"https://www.caniemail.com/tests/html-picture.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/AQoLHTLaC6F6JcMrkx38M7oyiJlAlXeRnJgkK06bSJiBR/list",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y"},"ios":{"10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-05":"n #1"},"ios":{"2019-05":"n #1"},"android":{"2019-05":"n #1"},"mobile-webmail":{"2020-02":"n #1"}},"orange":{"desktop-webmail":{"2019-05":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-05":"y","2024-04":"n"},"android":{"2019-05":"y","2024-04":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.62":"n","16.80":"n"},"outlook-com":{"2019-05":"n","2024-01":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-05":"y"},"ios":{"2019-05":"y"},"android":{"2019-05":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-05":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"n"},"android":{"2022-11":"n"}}},
|
||||
"test_results_url":"https://testi.at/proj/vr32cxxk1exntxrjfdp",
|
||||
"stats":{"apple-mail":{"macos":{"10.3":"y","10.15":"a #2","11.7":"a #2","12.7":"a #2","13.6":"a #2","14.4":"a #2"},"ios":{"10.3":"y","12.2":"y"}},"gmail":{"desktop-webmail":{"2019-05":"n #1"},"ios":{"2019-05":"n #1"},"android":{"2019-05":"n #1"},"mobile-webmail":{"2020-02":"n #1"}},"orange":{"desktop-webmail":{"2019-05":"y","2021-03":"n","2024-04":"n"},"ios":{"2019-05":"y","2024-04":"n"},"android":{"2019-05":"y","2024-04":"n"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.62":"n","16.80":"n"},"outlook-com":{"2019-05":"n","2024-01":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-05":"y"},"ios":{"2019-05":"y"},"android":{"2019-05":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-05":"n"},"ios":{"2019-05":"n"},"android":{"2019-05":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"n"}},"hey":{"desktop-webmail":{"2020-06":"n"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"web-de":{"desktop-webmail":{"2022-11":"n"},"ios":{"2022-11":"y"},"android":{"2022-11":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"n"},"android":{"2022-11":"n"}}},
|
||||
"notes":"",
|
||||
"notes_by_num":{"1":"`<picture>` and `<source>` tags are replaced by `<u></u>` tags."}
|
||||
"notes_by_num":{"1":"`<picture>` and `<source>` tags are replaced by `<u></u>` tags.","2":"`<picture>` tag is stripped in some cases (like having too few content or no background-color)."}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -4510,7 +4510,7 @@
|
||||
"last_test_date":"2023-01-15",
|
||||
"test_url":"https://www.caniemail.com/tests/images.html",
|
||||
"test_results_url":"https://app.emailonacid.com/app/acidtest/xm1T5nQ1MKtHpVSJidhagmt3Z53CjqbkMhorlvuM0Gz57/list",
|
||||
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"stats":{"apple-mail":{"macos":{"13":"n","14":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2020-02":"n","2023-01":"n","2024-07":"n"},"ios":{"2020-02":"a #1","2023-01":"a #1"},"android":{"2020-02":"a #1","2023-01":"a #1"},"mobile-webmail":{"2020-02":"n","2023-01":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2020-02":"y"},"macos":{"2016":"n","13.1":"y","16.80":"y"},"outlook-com":{"2020-02":"y","2024-01":"y"},"ios":{"2020-02":"n","2023-01":"y"},"android":{"2020-02":"y"}},"samsung-email":{"android":{"9.0":"y"}},"thunderbird":{"windows":{"2020-02":"y"},"macos":{"68.4":"y"}},"aol":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"yahoo":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2020-02":"y","2021-03":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"sfr":{"desktop-webmail":{"2020-02":"y"},"ios":{"2020-02":"y"},"android":{"2020-02":"y"}},"protonmail":{"desktop-webmail":{"2020-03":"y"},"ios":{"2020-03":"y"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n","2023-01":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"y"}},"laposte":{"desktop-webmail":{"2021-08":"y"}},"gmx":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"y"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
|
||||
"notes":null,
|
||||
"notes_by_num":{"1":"Partially supported. Only works with non Google accounts."}
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
@@ -48,34 +49,67 @@ func dbCron() {
|
||||
// PruneMessages will auto-delete the oldest messages if messages > config.MaxMessages.
|
||||
// Set config.MaxMessages to 0 to disable.
|
||||
func pruneMessages() {
|
||||
if config.MaxMessages < 1 {
|
||||
if config.MaxMessages < 1 && config.MaxAgeInHours == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
|
||||
ids := []string{}
|
||||
var prunedSize int64
|
||||
var size float64
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
// prune using `--max` if set
|
||||
if config.MaxMessages > 0 {
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
OrderBy("Created DESC").
|
||||
Limit(5000).
|
||||
Offset(config.MaxMessages)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
// prune using `--max-age` if set
|
||||
if config.MaxAgeInHours > 0 {
|
||||
// now() minus the number of hours
|
||||
ts := time.Now().Add(time.Duration(-config.MaxAgeInHours) * time.Hour).UnixMilli()
|
||||
|
||||
q := sqlf.Select("ID, Size").
|
||||
From(tenant("mailbox")).
|
||||
Where("Created < ?", ts).
|
||||
Limit(5000)
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var id string
|
||||
|
||||
if err := row.Scan(&id, &size); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !tools.InArray(id, ids) {
|
||||
ids = append(ids, id)
|
||||
prunedSize = prunedSize + int64(size)
|
||||
}
|
||||
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
|
||||
@@ -50,7 +50,7 @@ func Store(body *[]byte) (string, error) {
|
||||
ReplyTo: addressToSlice(env, "Reply-To"),
|
||||
}
|
||||
|
||||
messageID := strings.Trim(env.Root.Header.Get("Message-ID"), "<>")
|
||||
messageID := strings.Trim(env.GetHeader("Message-ID"), "<>")
|
||||
created := time.Now()
|
||||
|
||||
// use message date instead of created date
|
||||
@@ -116,7 +116,7 @@ func Store(body *[]byte) (string, error) {
|
||||
tags := findTagsInRawMessage(body)
|
||||
|
||||
if !config.TagsDisableXTags {
|
||||
xTagsHdr := env.Root.Header.Get("X-Tags")
|
||||
xTagsHdr := env.GetHeader("X-Tags")
|
||||
if xTagsHdr != "" {
|
||||
// extract tags from X-Tags header
|
||||
tags = append(tags, tools.SetTagCasing(strings.Split(strings.TrimSpace(xTagsHdr), ","))...)
|
||||
@@ -160,12 +160,14 @@ func Store(body *[]byte) (string, error) {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
logger.Log().Debugf("[db] saved message %s (%d bytes)", id, int64(size))
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// List returns a subset of messages from the mailbox,
|
||||
// sorted latest to oldest
|
||||
func List(start, limit int) ([]MessageSummary, error) {
|
||||
func List(start int, beforeTS int64, limit int) ([]MessageSummary, error) {
|
||||
results := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
|
||||
@@ -175,6 +177,10 @@ func List(start, limit int) ([]MessageSummary, error) {
|
||||
Limit(limit).
|
||||
Offset(start)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where("Created < ?", beforeTS)
|
||||
}
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
var created float64
|
||||
var id string
|
||||
@@ -428,12 +434,12 @@ func LatestID(r *http.Request) (string, error) {
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search != "" {
|
||||
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 1)
|
||||
messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 0, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
messages, err = List(0, 1)
|
||||
messages, err = List(0, 0, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -462,6 +468,13 @@ func MarkRead(id string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: true}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -534,6 +547,13 @@ func MarkUnread(id string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Read bool
|
||||
}{ID: id, Read: false}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -621,6 +641,15 @@ func DeleteMessages(ids []string) error {
|
||||
|
||||
BroadcastMailboxStats()
|
||||
|
||||
// broadcast individual message deletions
|
||||
for _, id := range toDelete {
|
||||
d := struct {
|
||||
ID string
|
||||
}{ID: id}
|
||||
|
||||
websockets.Broadcast("delete", d)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -671,8 +700,9 @@ func DeleteAllMessages() error {
|
||||
|
||||
logMessagesDeleted(total)
|
||||
|
||||
websockets.Broadcast("prune", nil)
|
||||
BroadcastMailboxStats()
|
||||
|
||||
websockets.Broadcast("truncate", nil)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestMessageSummary(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
summaries, err := List(0, 1)
|
||||
summaries, err := List(0, 0, 1)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as:
|
||||
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
|
||||
// Negative searches also also included by prefixing the search term with a `-` or `!`
|
||||
func Search(search, timezone string, start, limit int) ([]MessageSummary, int, error) {
|
||||
func Search(search, timezone string, start int, beforeTS int64, limit int) ([]MessageSummary, int, error) {
|
||||
results := []MessageSummary{}
|
||||
allResults := []MessageSummary{}
|
||||
tsStart := time.Now()
|
||||
@@ -28,6 +28,11 @@ func Search(search, timezone string, start, limit int) ([]MessageSummary, int, e
|
||||
}
|
||||
|
||||
q := searchQueryBuilder(search, timezone)
|
||||
|
||||
if beforeTS > 0 {
|
||||
q = q.Where(`Created < ?`, beforeTS)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestSearch(t *testing.T) {
|
||||
|
||||
search := uniqueSearches[searchIdx]
|
||||
|
||||
summaries, _, err := Search(search, "", 0, 100)
|
||||
summaries, _, err := Search(search, "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -85,7 +85,7 @@ func TestSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
// search something that will return 200 results
|
||||
summaries, _, err := Search("This is the email body", "", 0, testRuns)
|
||||
summaries, _, err := Search("This is the email body", "", 0, 0, testRuns)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -109,7 +109,7 @@ func TestSearchDelete100(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 100)
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -122,7 +122,7 @@ func TestSearchDelete100(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -143,7 +143,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 100)
|
||||
_, total, err := Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -156,7 +156,7 @@ func TestSearchDelete1100(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 100)
|
||||
_, total, err = Search("from:sender@example.com", "", 0, 0, 100)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/tools"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/leporo/sqlf"
|
||||
)
|
||||
|
||||
@@ -40,7 +41,7 @@ func SetMessageTags(id string, tags []string) ([]string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
name, err := AddMessageTag(id, t)
|
||||
name, err := addMessageTag(id, t)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
@@ -53,18 +54,25 @@ func SetMessageTags(id string, tags []string) ([]string, error) {
|
||||
|
||||
for _, t := range currentTags {
|
||||
if !tools.InArray(t, applyTags) {
|
||||
if err := DeleteMessageTag(id, t); err != nil {
|
||||
if err := deleteMessageTag(id, t); err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d := struct {
|
||||
ID string
|
||||
Tags []string
|
||||
}{ID: id, Tags: applyTags}
|
||||
|
||||
websockets.Broadcast("update", d)
|
||||
|
||||
return tagNames, nil
|
||||
}
|
||||
|
||||
// AddMessageTag adds a tag to a message
|
||||
func AddMessageTag(id, name string) (string, error) {
|
||||
func addMessageTag(id, name string) (string, error) {
|
||||
// prevent two identical tags being added at the same time
|
||||
addTagMutex.Lock()
|
||||
|
||||
@@ -114,11 +122,11 @@ func AddMessageTag(id, name string) (string, error) {
|
||||
addTagMutex.Unlock()
|
||||
|
||||
// add tag to the message
|
||||
return AddMessageTag(id, name)
|
||||
return addMessageTag(id, name)
|
||||
}
|
||||
|
||||
// DeleteMessageTag deleted a tag from a message
|
||||
func DeleteMessageTag(id, name string) error {
|
||||
// DeleteMessageTag deletes a tag from a message
|
||||
func deleteMessageTag(id, name string) error {
|
||||
if _, err := sqlf.DeleteFrom(tenant("message_tags")).
|
||||
Where(tenant("message_tags.ID")+" = ?", id).
|
||||
Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN tags ON `+tenant("TagID")+"="+tenant("tags.ID")+` WHERE Name = ?)`, name).
|
||||
@@ -173,7 +181,6 @@ func GetAllTagsCount() map[string]int64 {
|
||||
OrderBy("Name").
|
||||
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
|
||||
tags[name] = total
|
||||
// tags = append(tags, name)
|
||||
}); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestTags(t *testing.T) {
|
||||
assertEqual(t, strings.Join(newTags, "|"), strings.Join(returnedTags, "|"), "Message tags do not match")
|
||||
|
||||
// remove first tag
|
||||
if err := DeleteMessageTag(id, newTags[0]); err != nil {
|
||||
if err := deleteMessageTag(id, newTags[0]); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
398
package-lock.json
generated
398
package-lock.json
generated
@@ -14,7 +14,9 @@
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
"color-hash": "^2.0.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.1.6",
|
||||
"ical.js": "^2.0.1",
|
||||
"mitt": "^3.0.1",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
@@ -41,10 +43,29 @@
|
||||
"swagger-client": "^3.18.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.24.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
|
||||
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz",
|
||||
"integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
|
||||
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz",
|
||||
"integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.25.2"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
@@ -64,6 +85,19 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz",
|
||||
"integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.24.8",
|
||||
"@babel/helper-validator-identifier": "^7.24.7",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz",
|
||||
@@ -72,9 +106,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz",
|
||||
"integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
|
||||
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -88,9 +122,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz",
|
||||
"integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
|
||||
"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -104,9 +138,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz",
|
||||
"integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
|
||||
"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -120,9 +154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz",
|
||||
"integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
|
||||
"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -136,9 +170,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz",
|
||||
"integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
|
||||
"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -152,9 +186,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz",
|
||||
"integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
|
||||
"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -168,9 +202,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz",
|
||||
"integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
|
||||
"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -184,9 +218,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz",
|
||||
"integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
|
||||
"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -200,9 +234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz",
|
||||
"integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
|
||||
"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -216,9 +250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz",
|
||||
"integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
|
||||
"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -232,9 +266,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz",
|
||||
"integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
|
||||
"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -248,9 +282,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz",
|
||||
"integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
|
||||
"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -264,9 +298,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz",
|
||||
"integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
|
||||
"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -280,9 +314,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz",
|
||||
"integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
|
||||
"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -296,9 +330,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz",
|
||||
"integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
|
||||
"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -312,9 +346,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz",
|
||||
"integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
|
||||
"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -328,9 +362,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz",
|
||||
"integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
|
||||
"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -344,9 +378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz",
|
||||
"integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
|
||||
"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -360,9 +394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz",
|
||||
"integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
|
||||
"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -376,9 +410,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz",
|
||||
"integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
|
||||
"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -392,9 +426,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz",
|
||||
"integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
|
||||
"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -408,9 +442,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz",
|
||||
"integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
|
||||
"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -424,9 +458,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz",
|
||||
"integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
|
||||
"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -440,9 +474,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz",
|
||||
"integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
|
||||
"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -954,49 +988,49 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.31.tgz",
|
||||
"integrity": "sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.38.tgz",
|
||||
"integrity": "sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.24.7",
|
||||
"@vue/shared": "3.4.31",
|
||||
"@vue/shared": "3.4.38",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.31.tgz",
|
||||
"integrity": "sha512-wK424WMXsG1IGMyDGyLqB+TbmEBFM78hIsOJ9QwUVLGrcSk0ak6zYty7Pj8ftm7nEtdU/DGQxAXp0/lM/2cEpQ==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.38.tgz",
|
||||
"integrity": "sha512-Osc/c7ABsHXTsETLgykcOwIxFktHfGSUDkb05V61rocEfsFDcjDLH/IHJSNJP+/Sv9KeN2Lx1V6McZzlSb9EhQ==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.4.31",
|
||||
"@vue/shared": "3.4.31"
|
||||
"@vue/compiler-core": "3.4.38",
|
||||
"@vue/shared": "3.4.38"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.31.tgz",
|
||||
"integrity": "sha512-einJxqEw8IIJxzmnxmJBuK2usI+lJonl53foq+9etB2HAzlPjAS/wa7r0uUpXw5ByX3/0uswVSrjNb17vJm1kQ==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.38.tgz",
|
||||
"integrity": "sha512-s5QfZ+9PzPh3T5H4hsQDJtI8x7zdJaew/dCGgqZ2630XdzaZ3AD8xGZfBqpT8oaD/p2eedd+pL8tD5vvt5ZYJQ==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.24.7",
|
||||
"@vue/compiler-core": "3.4.31",
|
||||
"@vue/compiler-dom": "3.4.31",
|
||||
"@vue/compiler-ssr": "3.4.31",
|
||||
"@vue/shared": "3.4.31",
|
||||
"@vue/compiler-core": "3.4.38",
|
||||
"@vue/compiler-dom": "3.4.38",
|
||||
"@vue/compiler-ssr": "3.4.38",
|
||||
"@vue/shared": "3.4.38",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.10",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss": "^8.4.40",
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.31.tgz",
|
||||
"integrity": "sha512-RtefmITAje3fJ8FSg1gwgDhdKhZVntIVbwupdyZDSifZTRMiWxWehAOTCc8/KZDnBOcYQ4/9VWxsTbd3wT0hAA==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.38.tgz",
|
||||
"integrity": "sha512-YXznKFQ8dxYpAz9zLuVvfcXhc31FSPFDcqr0kyujbOwNhlmaNvL2QfIy+RZeJgSn5Fk54CWoEUeW+NVBAogGaw==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.4.31",
|
||||
"@vue/shared": "3.4.31"
|
||||
"@vue/compiler-dom": "3.4.38",
|
||||
"@vue/shared": "3.4.38"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
@@ -1005,49 +1039,49 @@
|
||||
"integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw=="
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.31.tgz",
|
||||
"integrity": "sha512-VGkTani8SOoVkZNds1PfJ/T1SlAIOf8E58PGAhIOUDYPC4GAmFA2u/E14TDAFcf3vVDKunc4QqCe/SHr8xC65Q==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.38.tgz",
|
||||
"integrity": "sha512-4vl4wMMVniLsSYYeldAKzbk72+D3hUnkw9z8lDeJacTxAkXeDAP1uE9xr2+aKIN0ipOL8EG2GPouVTH6yF7Gnw==",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.4.31"
|
||||
"@vue/shared": "3.4.38"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.31.tgz",
|
||||
"integrity": "sha512-LDkztxeUPazxG/p8c5JDDKPfkCDBkkiNLVNf7XZIUnJ+66GVGkP+TIh34+8LtPisZ+HMWl2zqhIw0xN5MwU1cw==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.38.tgz",
|
||||
"integrity": "sha512-21z3wA99EABtuf+O3IhdxP0iHgkBs1vuoCAsCKLVJPEjpVqvblwBnTj42vzHRlWDCyxu9ptDm7sI2ZMcWrQqlA==",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.4.31",
|
||||
"@vue/shared": "3.4.31"
|
||||
"@vue/reactivity": "3.4.38",
|
||||
"@vue/shared": "3.4.38"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.31.tgz",
|
||||
"integrity": "sha512-2Auws3mB7+lHhTFCg8E9ZWopA6Q6L455EcU7bzcQ4x6Dn4cCPuqj6S2oBZgN2a8vJRS/LSYYxwFFq2Hlx3Fsaw==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.38.tgz",
|
||||
"integrity": "sha512-afZzmUreU7vKwKsV17H1NDThEEmdYI+GCAK/KY1U957Ig2NATPVjCROv61R19fjZNzMmiU03n79OMnXyJVN0UA==",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.4.31",
|
||||
"@vue/runtime-core": "3.4.31",
|
||||
"@vue/shared": "3.4.31",
|
||||
"@vue/reactivity": "3.4.38",
|
||||
"@vue/runtime-core": "3.4.38",
|
||||
"@vue/shared": "3.4.38",
|
||||
"csstype": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.31.tgz",
|
||||
"integrity": "sha512-D5BLbdvrlR9PE3by9GaUp1gQXlCNadIZytMIb8H2h3FMWJd4oUfkUTEH2wAr3qxoRz25uxbTcbqd3WKlm9EHQA==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.38.tgz",
|
||||
"integrity": "sha512-NggOTr82FbPEkkUvBm4fTGcwUY8UuTsnWC/L2YZBmvaQ4C4Jl/Ao4HHTB+l7WnFCt5M/dN3l0XLuyjzswGYVCA==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.4.31",
|
||||
"@vue/shared": "3.4.31"
|
||||
"@vue/compiler-ssr": "3.4.38",
|
||||
"@vue/shared": "3.4.38"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.4.31"
|
||||
"vue": "3.4.38"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.31.tgz",
|
||||
"integrity": "sha512-Yp3wtJk//8cO4NItOPpi3QkLExAr/aLBGZMmTtW9WpdwBCJpRM6zj9WgWktXAl8IDIozwNMByT45JP3tO3ACWA=="
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.38.tgz",
|
||||
"integrity": "sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw=="
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
@@ -1078,9 +1112,9 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
|
||||
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
|
||||
"integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -1200,9 +1234,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/bootstrap5-tags": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap5-tags/-/bootstrap5-tags-1.7.2.tgz",
|
||||
"integrity": "sha512-i4XksM6bhZGHlXnnTlwM7HMM549/eUsTsXvptFrbn2JlCHeIi4yFdL0NHP5pRw4eg4e33Girp9iIWqd2KN8DTA=="
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap5-tags/-/bootstrap5-tags-1.7.4.tgz",
|
||||
"integrity": "sha512-eQWu6/EHGf45R8EFdTCk8JQeY5oqfvQvEaYNZmFttnN56YOFpD7U+S5snEngsxZArwj57BLa9Yf0/QOggXJhcg=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
@@ -1348,9 +1382,9 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.11",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
|
||||
"integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg=="
|
||||
"version": "1.11.12",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz",
|
||||
"integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg=="
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
@@ -1417,6 +1451,11 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
|
||||
"integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ=="
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
@@ -1457,9 +1496,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz",
|
||||
"integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==",
|
||||
"version": "0.23.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
|
||||
"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
@@ -1469,30 +1508,30 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.23.0",
|
||||
"@esbuild/android-arm": "0.23.0",
|
||||
"@esbuild/android-arm64": "0.23.0",
|
||||
"@esbuild/android-x64": "0.23.0",
|
||||
"@esbuild/darwin-arm64": "0.23.0",
|
||||
"@esbuild/darwin-x64": "0.23.0",
|
||||
"@esbuild/freebsd-arm64": "0.23.0",
|
||||
"@esbuild/freebsd-x64": "0.23.0",
|
||||
"@esbuild/linux-arm": "0.23.0",
|
||||
"@esbuild/linux-arm64": "0.23.0",
|
||||
"@esbuild/linux-ia32": "0.23.0",
|
||||
"@esbuild/linux-loong64": "0.23.0",
|
||||
"@esbuild/linux-mips64el": "0.23.0",
|
||||
"@esbuild/linux-ppc64": "0.23.0",
|
||||
"@esbuild/linux-riscv64": "0.23.0",
|
||||
"@esbuild/linux-s390x": "0.23.0",
|
||||
"@esbuild/linux-x64": "0.23.0",
|
||||
"@esbuild/netbsd-x64": "0.23.0",
|
||||
"@esbuild/openbsd-arm64": "0.23.0",
|
||||
"@esbuild/openbsd-x64": "0.23.0",
|
||||
"@esbuild/sunos-x64": "0.23.0",
|
||||
"@esbuild/win32-arm64": "0.23.0",
|
||||
"@esbuild/win32-ia32": "0.23.0",
|
||||
"@esbuild/win32-x64": "0.23.0"
|
||||
"@esbuild/aix-ppc64": "0.23.1",
|
||||
"@esbuild/android-arm": "0.23.1",
|
||||
"@esbuild/android-arm64": "0.23.1",
|
||||
"@esbuild/android-x64": "0.23.1",
|
||||
"@esbuild/darwin-arm64": "0.23.1",
|
||||
"@esbuild/darwin-x64": "0.23.1",
|
||||
"@esbuild/freebsd-arm64": "0.23.1",
|
||||
"@esbuild/freebsd-x64": "0.23.1",
|
||||
"@esbuild/linux-arm": "0.23.1",
|
||||
"@esbuild/linux-arm64": "0.23.1",
|
||||
"@esbuild/linux-ia32": "0.23.1",
|
||||
"@esbuild/linux-loong64": "0.23.1",
|
||||
"@esbuild/linux-mips64el": "0.23.1",
|
||||
"@esbuild/linux-ppc64": "0.23.1",
|
||||
"@esbuild/linux-riscv64": "0.23.1",
|
||||
"@esbuild/linux-s390x": "0.23.1",
|
||||
"@esbuild/linux-x64": "0.23.1",
|
||||
"@esbuild/netbsd-x64": "0.23.1",
|
||||
"@esbuild/openbsd-arm64": "0.23.1",
|
||||
"@esbuild/openbsd-x64": "0.23.1",
|
||||
"@esbuild/sunos-x64": "0.23.1",
|
||||
"@esbuild/win32-arm64": "0.23.1",
|
||||
"@esbuild/win32-ia32": "0.23.1",
|
||||
"@esbuild/win32-x64": "0.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-plugin-vue-next": {
|
||||
@@ -1948,6 +1987,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -2114,9 +2158,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.38",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
|
||||
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
|
||||
"version": "8.4.40",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
|
||||
"integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2133,7 +2177,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.0",
|
||||
"picocolors": "^1.0.1",
|
||||
"source-map-js": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2951,6 +2995,14 @@
|
||||
"resolved": "https://registry.npmjs.org/timezones-list/-/timezones-list-3.0.3.tgz",
|
||||
"integrity": "sha512-C+Vdvvj2c1xB6pu81pOX8geo6mrk/QsudFVlTVQET7QQwu8WAIyhDNeCrK5grU7EMzmbKLWqz7uU6dN8fvQvPQ=="
|
||||
},
|
||||
"node_modules/to-fast-properties": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
||||
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -3061,15 +3113,15 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.4.31",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.31.tgz",
|
||||
"integrity": "sha512-njqRrOy7W3YLAlVqSKpBebtZpDVg21FPoaq1I7f/+qqBThK9ChAIjkRWgeP6Eat+8C+iia4P3OYqpATP21BCoQ==",
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.38.tgz",
|
||||
"integrity": "sha512-f0ZgN+mZ5KFgVv9wz0f4OgVKukoXtS3nwET4c2vLBGQR50aI8G0cqbFtLlX9Yiyg3LFGBitruPHt2PxwTduJEw==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.4.31",
|
||||
"@vue/compiler-sfc": "3.4.31",
|
||||
"@vue/runtime-dom": "3.4.31",
|
||||
"@vue/server-renderer": "3.4.31",
|
||||
"@vue/shared": "3.4.31"
|
||||
"@vue/compiler-dom": "3.4.38",
|
||||
"@vue/compiler-sfc": "3.4.38",
|
||||
"@vue/runtime-dom": "3.4.38",
|
||||
"@vue/server-renderer": "3.4.38",
|
||||
"@vue/shared": "3.4.38"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
@@ -3089,11 +3141,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.0.tgz",
|
||||
"integrity": "sha512-HB+t2p611aIZraV2aPSRNXf0Z/oLZFrlygJm+sZbdJaW6lcFqEDQwnzUBXn+DApw+/QzDU/I9TeWx9izEjTmsA==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.3.tgz",
|
||||
"integrity": "sha512-sv6wmNKx2j3aqJQDMxLFzs/u/mjA9Z5LCgy6BE0f7yFWMjrPLnS/sPNn8ARY/FXw6byV18EFutn5lTO6+UsV5A==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.5.1"
|
||||
"@vue/devtools-api": "^6.6.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"bootstrap5-tags": "^1.6.1",
|
||||
"color-hash": "^2.0.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.1.6",
|
||||
"ical.js": "^2.0.1",
|
||||
"mitt": "^3.0.1",
|
||||
"modern-screenshot": "^4.4.30",
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
|
||||
@@ -10,12 +10,15 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/araddon/dateparse"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/htmlcheck"
|
||||
"github.com/axllent/mailpit/internal/linkcheck"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/spamassassin"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/jhillyerd/enmime"
|
||||
)
|
||||
|
||||
// GetMessages returns a paginated list of messages as JSON
|
||||
@@ -48,9 +51,9 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
|
||||
// Responses:
|
||||
// 200: MessagesSummaryResponse
|
||||
// default: ErrorResponse
|
||||
start, limit := getStartLimit(r)
|
||||
start, beforeTS, limit := getStartLimit(r)
|
||||
|
||||
messages, err := storage.List(start, limit)
|
||||
messages, err := storage.List(start, beforeTS, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -120,9 +123,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
start, limit := getStartLimit(r)
|
||||
start, beforeTS, limit := getStartLimit(r)
|
||||
|
||||
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
|
||||
messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, beforeTS, limit)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
@@ -442,7 +445,7 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/plain")
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
@@ -548,12 +551,20 @@ func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
raw, err := storage.GetMessageRaw(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
e := bytes.NewReader(raw)
|
||||
|
||||
msg, err := enmime.ReadEnvelope(e)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
return
|
||||
@@ -704,9 +715,10 @@ func httpJSONError(w http.ResponseWriter, msg string) {
|
||||
}
|
||||
|
||||
// Get the start and limit based on query params. Defaults to 0, 50
|
||||
func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
func getStartLimit(req *http.Request) (start int, beforeTS int64, limit int) {
|
||||
start = 0
|
||||
limit = 50
|
||||
beforeTS = 0 // timestamp
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
@@ -718,7 +730,17 @@ func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
limit = n
|
||||
}
|
||||
|
||||
return start, limit
|
||||
b := req.URL.Query().Get("before")
|
||||
if b != "" {
|
||||
t, err := dateparse.ParseLocal(b)
|
||||
if err != nil {
|
||||
logger.Log().Warnf("ignoring invalid before: date \"%s\"", b)
|
||||
} else {
|
||||
beforeTS = t.UnixMilli()
|
||||
}
|
||||
}
|
||||
|
||||
return start, beforeTS, limit
|
||||
}
|
||||
|
||||
// GetOptions returns a blank response
|
||||
|
||||
@@ -19,13 +19,13 @@ func RedirectToLatestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
search := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if search != "" {
|
||||
messages, _, err = storage.Search(search, "", 0, 1)
|
||||
messages, _, err = storage.Search(search, "", 0, 0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
messages, err = storage.List(0, 1)
|
||||
messages, err = storage.List(0, 0, 1)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
)
|
||||
|
||||
func authUser(username, password string) bool {
|
||||
@@ -19,6 +20,11 @@ func authUser(username, password string) bool {
|
||||
func sendResponse(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
logger.Log().Debugf("[pop3] response: %s", m)
|
||||
|
||||
if strings.HasPrefix(m, "-ERR ") {
|
||||
sub, _ := strings.CutPrefix(m, "-ERR ")
|
||||
websockets.BroadCastClientError("error", "pop3", c.RemoteAddr().String(), sub)
|
||||
}
|
||||
}
|
||||
|
||||
// Send a response without debug logging (for data)
|
||||
@@ -26,9 +32,10 @@ func sendData(c net.Conn, m string) {
|
||||
fmt.Fprintf(c, "%s\r\n", m)
|
||||
}
|
||||
|
||||
// Get the latest 100 messages
|
||||
func getMessages() ([]message, error) {
|
||||
messages := []message{}
|
||||
list, err := storage.List(0, 100)
|
||||
list, err := storage.List(0, 0, 100)
|
||||
if err != nil {
|
||||
return messages, err
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/axllent/mailpit/server/pop3"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
)
|
||||
|
||||
//go:embed ui
|
||||
@@ -75,11 +76,11 @@ func Listen() {
|
||||
}
|
||||
|
||||
// UI shortcut
|
||||
r.HandleFunc(config.Webroot+"view/latest", handlers.RedirectToLatestMessage).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/latest", middleWareFunc(handlers.RedirectToLatestMessage)).Methods("GET")
|
||||
|
||||
// frontend testing
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", handlers.GetMessageHTML).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", handlers.GetMessageText).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.html", middleWareFunc(handlers.GetMessageHTML)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"view/{id}.txt", middleWareFunc(handlers.GetMessageText)).Methods("GET")
|
||||
|
||||
// web UI via virtual index.html
|
||||
r.PathPrefix(config.Webroot + "view/").Handler(middleWareFunc(index)).Methods("GET")
|
||||
@@ -179,7 +180,21 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Content-Security-Policy", config.ContentSecurityPolicy)
|
||||
|
||||
// generate a new random nonce on every request
|
||||
randomNonce := shortuuid.New()
|
||||
// header used to pass nonce through to function
|
||||
r.Header.Set("mp-nonce", randomNonce)
|
||||
|
||||
// Prevent JavaScript XSS by adding a nonce for script-src
|
||||
cspHeader := strings.Replace(
|
||||
config.ContentSecurityPolicy,
|
||||
"script-src 'self';",
|
||||
fmt.Sprintf("script-src 'nonce-%s';", randomNonce),
|
||||
1,
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Security-Policy", cspHeader)
|
||||
|
||||
if AccessControlAllowOrigin != "" && strings.HasPrefix(r.RequestURI, config.Webroot+"api/") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", AccessControlAllowOrigin)
|
||||
@@ -281,7 +296,7 @@ func swaggerBasePath(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
// Just returns the default HTML template
|
||||
func index(w http.ResponseWriter, _ *http.Request) {
|
||||
func index(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var h = `<!DOCTYPE html>
|
||||
<html lang="en" class="h-100">
|
||||
@@ -298,10 +313,12 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
<body class="h-100">
|
||||
<div class="container-fluid h-100 d-flex flex-column" id="app" data-webroot="{{ .Webroot }}" data-version="{{ .Version }}">
|
||||
<noscript>You require JavaScript to use this app.</noscript>
|
||||
<noscript class="alert alert-warning position-absolute top-50 start-50 translate-middle">
|
||||
You need a browser with JavaScript support to use Mailpit
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}"></script>
|
||||
<script src="{{ .Webroot }}dist/app.js?{{ .Version }}" nonce="{{ .Nonce }}"></script>
|
||||
</body>
|
||||
|
||||
</html>`
|
||||
@@ -314,9 +331,11 @@ func index(w http.ResponseWriter, _ *http.Request) {
|
||||
data := struct {
|
||||
Webroot string
|
||||
Version string
|
||||
Nonce string
|
||||
}{
|
||||
Webroot: config.Webroot,
|
||||
Version: config.Version,
|
||||
Nonce: r.Header.Get("mp-nonce"),
|
||||
}
|
||||
|
||||
buff := new(bytes.Buffer)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/stats"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/mhale/smtpd"
|
||||
)
|
||||
@@ -21,6 +22,9 @@ import (
|
||||
var (
|
||||
// DisableReverseDNS allows rDNS to be disabled
|
||||
DisableReverseDNS bool
|
||||
|
||||
warningResponse = regexp.MustCompile(`^4\d\d `)
|
||||
errorResponse = regexp.MustCompile(`^5\d\d `)
|
||||
)
|
||||
|
||||
// MailHandler handles the incoming message to store in the database
|
||||
@@ -38,7 +42,7 @@ func Store(origin net.Addr, from string, to []string, data []byte) (string, erro
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
logger.Log().Errorf("[smtpd] error parsing message: %s", err.Error())
|
||||
logger.Log().Warnf("[smtpd] error parsing message: %s", err.Error())
|
||||
stats.LogSMTPRejected()
|
||||
return "", err
|
||||
}
|
||||
@@ -210,7 +214,17 @@ func Listen() error {
|
||||
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
|
||||
}
|
||||
|
||||
// Translate the smtpd verb from READ/WRITE
|
||||
func verbLogTranslator(verb string) string {
|
||||
if verb == "READ" {
|
||||
return "received"
|
||||
}
|
||||
|
||||
return "response"
|
||||
}
|
||||
|
||||
func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.AuthHandler) error {
|
||||
smtpd.Debug = true // to enable Mailpit logging
|
||||
srv := &smtpd.Server{
|
||||
Addr: addr,
|
||||
MsgIDHandler: handler,
|
||||
@@ -221,6 +235,20 @@ func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.A
|
||||
AuthRequired: false,
|
||||
MaxRecipients: config.SMTPMaxRecipients,
|
||||
DisableReverseDNS: DisableReverseDNS,
|
||||
LogRead: func(remoteIP, verb, line string) {
|
||||
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
},
|
||||
LogWrite: func(remoteIP, verb, line string) {
|
||||
if warningResponse.MatchString(line) {
|
||||
logger.Log().Warnf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
websockets.BroadCastClientError("warning", "smtpd", remoteIP, line)
|
||||
} else if errorResponse.MatchString(line) {
|
||||
logger.Log().Errorf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
websockets.BroadCastClientError("error", "smtpd", remoteIP, line)
|
||||
} else {
|
||||
logger.Log().Debugf("[smtpd] %s (%s) %s", verbLogTranslator(verb), remoteIP, line)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if config.Label != "" {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createApp } from 'vue'
|
||||
import mitt from 'mitt';
|
||||
|
||||
import './assets/styles.scss'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.scss'
|
||||
import 'bootstrap'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Global event bus used to subscribe to websocket events
|
||||
// such as message deletes, updates & truncation.
|
||||
const eventBus = mitt()
|
||||
app.provide('eventBus', eventBus)
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -91,44 +91,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.about-mailpit {
|
||||
@include media-breakpoint-down(md) {
|
||||
width: var(--bs-offcanvas-width);
|
||||
margin-left: -1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
.subject {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.read {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
background: var(--bs-primary-bg-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.text-spaces-nowrap {
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -266,8 +228,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item.message:first-child {
|
||||
border-top: 0;
|
||||
#message-page {
|
||||
.list-group-item.message:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
.subject {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
|
||||
small {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.read {
|
||||
color: $text-muted;
|
||||
|
||||
b {
|
||||
color: $list-group-color;
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
background: var(--bs-primary-bg-subtle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.blur {
|
||||
@@ -320,6 +309,18 @@ body.blur {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
&.read {
|
||||
> div {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#message-view {
|
||||
.form-control.dropdown {
|
||||
padding: 0;
|
||||
|
||||
@@ -54,14 +54,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<template v-if="!modals">
|
||||
<div
|
||||
class="position-fixed bg-body bottom-0 ms-n1 py-2 text-muted small col-xl-2 col-md-3 pe-3 z-3 about-mailpit">
|
||||
<button class="text-muted btn btn-sm" v-on:click="loadInfo">
|
||||
<div class="bg-body ms-sm-n1 me-sm-n1 py-2 text-muted small about-mailpit">
|
||||
<button class="text-muted btn btn-sm ps-0" v-on:click="loadInfo()">
|
||||
<i class="bi bi-info-circle-fill me-1"></i>
|
||||
About
|
||||
</button>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary float-end me-2" data-bs-toggle="modal"
|
||||
<button class="btn btn-sm btn-outline-secondary float-end" data-bs-toggle="modal"
|
||||
data-bs-target="#SettingsModal" title="Mailpit UI settings">
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
</button>
|
||||
@@ -152,7 +151,7 @@ export default {
|
||||
<div class="card-header h4">
|
||||
Runtime statistics
|
||||
<button class="btn btn-sm btn-outline-secondary float-end"
|
||||
v-on:click="loadInfo">
|
||||
v-on:click="loadInfo()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -183,8 +182,8 @@ export default {
|
||||
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
|
||||
<small class="text-secondary">
|
||||
({{
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
}})
|
||||
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
|
||||
}})
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -20,9 +20,12 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
created() {
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refreshUI()
|
||||
},
|
||||
|
||||
@@ -139,7 +142,7 @@ export default {
|
||||
</b>
|
||||
</div>
|
||||
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
||||
{{ getPrimaryEmailTo(message) }}
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
|
||||
@@ -131,8 +131,8 @@ export default {
|
||||
<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>.
|
||||
This will permanently delete {{ formatNumber(mailbox.total) }}
|
||||
message<span v-if="mailbox.total > 1">s</span>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
|
||||
@@ -6,8 +6,6 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
emits: ['loadMessages'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
@@ -24,7 +22,7 @@ export default {
|
||||
return false
|
||||
}
|
||||
|
||||
let re = new RegExp(`(^|\\s)tag:"?${tag}"?($|\\s)`, 'i')
|
||||
let re = new RegExp(`\\btag:("${tag}"|${tag}\\b)`, 'i')
|
||||
return query.match(re)
|
||||
},
|
||||
|
||||
@@ -82,7 +80,7 @@ export default {
|
||||
<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">
|
||||
<button class="btn btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Tags
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@@ -100,7 +98,7 @@ export default {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="list-group mt-1 mb-5 pb-3">
|
||||
<div class="list-group mt-1 mb-2">
|
||||
<RouterLink v-for="tag in mailbox.tags" :to="toTagUrl(tag)" @click="hideNav"
|
||||
v-on:click="pagination.start = 0" v-on:click.ctrl="toggleTag($event, tag)"
|
||||
:style="mailbox.showTagColors ? { borderLeftColor: colorHash(tag), borderLeftWidth: '4px' } : ''"
|
||||
|
||||
@@ -7,6 +7,9 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
data() {
|
||||
return {
|
||||
pagination,
|
||||
@@ -18,7 +21,7 @@ export default {
|
||||
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
|
||||
pauseNotifications: false, // prevent spamming
|
||||
version: false,
|
||||
paginationDelayed: false, // for delayed pagination URL changes
|
||||
clientErrors: [], // errors received via websocket
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,6 +40,8 @@ export default {
|
||||
mailbox.notificationsSupported = window.isSecureContext
|
||||
&& ("Notification" in window && Notification.permission !== "denied")
|
||||
mailbox.notificationsEnabled = mailbox.notificationsSupported && Notification.permission == "granted"
|
||||
|
||||
this.errorNotificationCron()
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -53,20 +58,7 @@ export default {
|
||||
|
||||
// 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++
|
||||
// prevent "Too many calls to Location or History APIs within a short timeframe"
|
||||
this.delayedPaginationUpdate()
|
||||
}
|
||||
}
|
||||
this.eventBus.emit("new", response.Data)
|
||||
|
||||
for (let i in response.Data.Tags) {
|
||||
if (mailbox.tags.findIndex(e => { return e.toLowerCase() === response.Data.Tags[i].toLowerCase() }) < 0) {
|
||||
@@ -91,6 +83,7 @@ export default {
|
||||
window.scrollInPlace = true
|
||||
mailbox.refresh = true // trigger refresh
|
||||
window.setTimeout(() => { mailbox.refresh = false }, 500)
|
||||
this.eventBus.emit("prune");
|
||||
} else if (response.Type == "stats" && response.Data) {
|
||||
// refresh mailbox stats
|
||||
mailbox.total = response.Data.Total
|
||||
@@ -100,6 +93,18 @@ export default {
|
||||
if (this.version != response.Data.Version) {
|
||||
location.reload()
|
||||
}
|
||||
} else if (response.Type == "delete" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("delete", response.Data)
|
||||
} else if (response.Type == "update" && response.Data) {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("update", response.Data)
|
||||
} else if (response.Type == "truncate") {
|
||||
// broadcast for components
|
||||
this.eventBus.emit("truncate")
|
||||
} else if (response.Type == "error") {
|
||||
// broadcast for components
|
||||
this.addClientError(response.Data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,39 +163,6 @@ export default {
|
||||
}, 15000)
|
||||
},
|
||||
|
||||
// This will only update the pagination offset at a maximum of 2x per second
|
||||
// when viewing the inbox on > page 1, while receiving an influx of new messages.
|
||||
delayedPaginationUpdate() {
|
||||
if (this.paginationDelayed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.paginationDelayed = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
const path = this.$route.path
|
||||
const p = {
|
||||
...this.$route.query
|
||||
}
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
} else {
|
||||
delete p.start
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
} else {
|
||||
delete p.limit
|
||||
}
|
||||
|
||||
mailbox.autoPaginating = false // prevent reload of messages when URL changes
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.replace(path + '?' + params.toString())
|
||||
|
||||
this.paginationDelayed = false
|
||||
}, 500)
|
||||
},
|
||||
|
||||
browserNotify(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return
|
||||
@@ -229,12 +201,43 @@ export default {
|
||||
Toast.getOrCreateInstance(el).hide()
|
||||
}
|
||||
},
|
||||
|
||||
addClientError(d) {
|
||||
d.expire = Date.now() + 5000 // expire after 5s
|
||||
this.clientErrors.push(d)
|
||||
},
|
||||
|
||||
errorNotificationCron() {
|
||||
window.setTimeout(() => {
|
||||
this.clientErrors.forEach((err, idx) => {
|
||||
if (err.expire < Date.now()) {
|
||||
this.clientErrors.splice(idx, 1)
|
||||
}
|
||||
})
|
||||
this.errorNotificationCron()
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div v-for="error in clientErrors" class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<svg class="bd-placeholder-img rounded me-2" width="20" height="20" xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true" preserveAspectRatio="xMidYMid slice" focusable="false">
|
||||
<rect width="100%" height="100%" :fill="error.Level == 'warning' ? '#ffc107' : '#dc3545'"></rect>
|
||||
</svg>
|
||||
<strong class="me-auto">{{ error.Type }}</strong>
|
||||
<small class="text-body-secondary">{{ error.IP }}</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ error.Message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import Tags from 'bootstrap5-tags'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
import commonMixins from '../../mixins/CommonMixins'
|
||||
import { mailbox } from '../../stores/mailbox'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -73,6 +74,57 @@ export default {
|
||||
return (mailbox.showHTMLCheck && this.message.HTML)
|
||||
|| mailbox.showLinkCheck
|
||||
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
|
||||
},
|
||||
|
||||
// remove bad HTML, JavaScript, iframes etc
|
||||
sanitizedHTML() {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.hasAttribute('href') && node.getAttribute('href').substring(0, 1) == '#') {
|
||||
return
|
||||
}
|
||||
if ('target' in node) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
|
||||
node.setAttribute('xlink:show', '_blank');
|
||||
}
|
||||
});
|
||||
|
||||
const clean = DOMPurify.sanitize(
|
||||
this.message.HTML,
|
||||
{
|
||||
WHOLE_DOCUMENT: true,
|
||||
SANITIZE_DOM: false,
|
||||
ADD_TAGS: [
|
||||
'link',
|
||||
'meta',
|
||||
'o:p',
|
||||
'style',
|
||||
],
|
||||
ADD_ATTR: [
|
||||
'bordercolor',
|
||||
'charset',
|
||||
'content',
|
||||
'hspace',
|
||||
'http-equiv',
|
||||
'itemprop',
|
||||
'itemscope',
|
||||
'itemtype',
|
||||
'link',
|
||||
'vertical-align',
|
||||
'vlink',
|
||||
'vspace',
|
||||
'xml:lang'
|
||||
],
|
||||
FORBID_ATTR: ['script'],
|
||||
}
|
||||
)
|
||||
|
||||
// for debugging
|
||||
// this.debugDOMPurify(DOMPurify.removed)
|
||||
|
||||
return clean
|
||||
}
|
||||
},
|
||||
|
||||
@@ -133,17 +185,19 @@ export default {
|
||||
// delay 0.2s until vue has rendered the iframe content
|
||||
window.setTimeout(() => {
|
||||
let p = document.getElementById('preview-html')
|
||||
if (p) {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i]
|
||||
let href = anchorEl.getAttribute('href')
|
||||
if (p && typeof p.contentWindow.document.body == 'object') {
|
||||
try {
|
||||
// make links open in new window
|
||||
let anchorEls = p.contentWindow.document.body.querySelectorAll('a')
|
||||
for (var i = 0; i < anchorEls.length; i++) {
|
||||
let anchorEl = anchorEls[i]
|
||||
let href = anchorEl.getAttribute('href')
|
||||
|
||||
if (href && href.match(/^http/)) {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
if (href && href.match(/^http/)) {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) { }
|
||||
this.resizeIFrames()
|
||||
}
|
||||
}, 200)
|
||||
@@ -156,7 +210,9 @@ export default {
|
||||
|
||||
resizeIframe(el) {
|
||||
let i = el.target
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
if (typeof i.contentWindow.document.body.scrollHeight == 'number') {
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
},
|
||||
|
||||
resizeIFrames() {
|
||||
@@ -165,7 +221,9 @@ export default {
|
||||
}
|
||||
let h = document.getElementById('preview-html')
|
||||
if (h) {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
if (typeof h.contentWindow.document.body.scrollHeight == 'number') {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
@@ -185,9 +243,31 @@ export default {
|
||||
this.resizeIframe(el)
|
||||
},
|
||||
|
||||
sanitizeHTML(h) {
|
||||
// remove <base/> tag if set
|
||||
return h.replace(/<base .*>/mi, '')
|
||||
// this function is unused but kept here to use for debugging
|
||||
debugDOMPurify(removed) {
|
||||
if (!removed.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const ignoreNodes = ['target', 'base', 'script', 'v:shapes']
|
||||
|
||||
let d = removed.filter((r) => {
|
||||
if (typeof r.attribute != 'undefined' &&
|
||||
(ignoreNodes.includes(r.attribute.nodeName) || r.attribute.nodeName.startsWith('xmlns:'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// inline comments
|
||||
if (typeof r.element != 'undefined' && (r.element.nodeType == 8 || r.element.tagName == 'SCRIPT')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (d.length) {
|
||||
console.log(d)
|
||||
}
|
||||
},
|
||||
|
||||
saveTags() {
|
||||
@@ -232,7 +312,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100" style="overflow-y: scroll;">
|
||||
<div v-if="message" id="message-view" class="px-2 px-md-0 mh-100">
|
||||
<div class="row w-100">
|
||||
<div class="col-md">
|
||||
<table class="messageHeaders">
|
||||
@@ -292,7 +372,7 @@ export default {
|
||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||
<th>Bcc</th>
|
||||
<td class="privacy">
|
||||
<span v-for="( t, i ) in message.Bcc ">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
<span class="text-spaces">{{ t.Name }}</span>
|
||||
<<a :href="searchURI(t.Address)" class="text-body">
|
||||
@@ -329,11 +409,13 @@ export default {
|
||||
<small class="text-body-secondary" v-else>[ no subject ]</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="d-md-none small">
|
||||
<tr class="small">
|
||||
<th class="small">Date</th>
|
||||
<td>{{ messageDate(message.Date) }}</td>
|
||||
<td>
|
||||
{{ messageDate(message.Date) }}
|
||||
<small class="ms-2">({{ getFileSize(message.Size) }})</small>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="small">
|
||||
<th>Tags</th>
|
||||
<td>
|
||||
@@ -510,9 +592,8 @@ export default {
|
||||
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
|
||||
aria-labelledby="nav-html-tab" tabindex="0">
|
||||
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html"
|
||||
:srcdoc="sanitizeHTML(message.HTML)" v-on:load="resizeIframe" frameborder="0"
|
||||
style="width: 100%; height: 100%; background: #fff;">
|
||||
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizedHTML"
|
||||
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;">
|
||||
</iframe>
|
||||
</div>
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
|
||||
@@ -115,8 +115,10 @@ export default {
|
||||
* @params function callback function
|
||||
* @params function error callback function
|
||||
*/
|
||||
get(url, values, callback, errorCallback) {
|
||||
this.loading++
|
||||
get(url, values, callback, errorCallback, hideLoader) {
|
||||
if (!hideLoader) {
|
||||
this.loading++
|
||||
}
|
||||
axios.get(url, { params: values })
|
||||
.then(callback)
|
||||
.catch((err) => {
|
||||
@@ -128,7 +130,7 @@ export default {
|
||||
})
|
||||
.then(() => {
|
||||
// always executed
|
||||
if (this.loading > 0) {
|
||||
if (!hideLoader && this.loading > 0) {
|
||||
this.loading--
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,6 +14,9 @@ import { pagination } from "../stores/pagination";
|
||||
export default {
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@@ -27,6 +30,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
mailbox,
|
||||
delayedRefresh: false,
|
||||
paginationDelayed: false, // for delayed pagination URL changes
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,6 +45,20 @@ export default {
|
||||
mailbox.searching = false
|
||||
this.apiURI = this.resolve(`/api/v1/messages`)
|
||||
this.loadMailbox()
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("new", this.handleWSNew)
|
||||
this.eventBus.on("update", this.handleWSUpdate)
|
||||
this.eventBus.on("delete", this.handleWSDelete)
|
||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// unsubscribe from events
|
||||
this.eventBus.off("new", this.handleWSNew)
|
||||
this.eventBus.off("update", this.handleWSUpdate)
|
||||
this.eventBus.off("delete", this.handleWSDelete)
|
||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -55,7 +74,100 @@ export default {
|
||||
}
|
||||
|
||||
this.loadMessages()
|
||||
}
|
||||
},
|
||||
|
||||
// This will only update the pagination offset at a maximum of 2x per second
|
||||
// when viewing the inbox on > page 1, while receiving an influx of new messages.
|
||||
delayedPaginationUpdate() {
|
||||
if (this.paginationDelayed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.paginationDelayed = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
const path = this.$route.path
|
||||
const p = {
|
||||
...this.$route.query
|
||||
}
|
||||
if (pagination.start > 0) {
|
||||
p.start = pagination.start.toString()
|
||||
} else {
|
||||
delete p.start
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
} else {
|
||||
delete p.limit
|
||||
}
|
||||
|
||||
mailbox.autoPaginating = false // prevent reload of messages when URL changes
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.replace(path + '?' + params.toString())
|
||||
|
||||
this.paginationDelayed = false
|
||||
}, 500)
|
||||
},
|
||||
|
||||
// handler for websocket new messages
|
||||
handleWSNew(data) {
|
||||
if (pagination.start < 1) {
|
||||
// push results directly into first page
|
||||
mailbox.messages.unshift(data)
|
||||
if (mailbox.messages.length > pagination.limit) {
|
||||
mailbox.messages.pop()
|
||||
}
|
||||
} else {
|
||||
// update pagination offset
|
||||
pagination.start++
|
||||
// prevent "Too many calls to Location or History APIs within a short time frame"
|
||||
this.delayedPaginationUpdate()
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||
if (this.mailbox.messages[x].ID == data.ID) {
|
||||
// update message
|
||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message deletion
|
||||
handleWSDelete(data) {
|
||||
let removed = 0;
|
||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||
if (this.mailbox.messages[x].ID == data.ID) {
|
||||
// remove message from the list
|
||||
this.mailbox.messages.splice(x, 1)
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed || this.delayedRefresh) {
|
||||
// nothing changed on this screen, or a refresh is queued,
|
||||
// don't refresh
|
||||
return
|
||||
}
|
||||
|
||||
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||
this.delayedRefresh = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.delayedRefresh = false
|
||||
this.loadMessages()
|
||||
}, 500)
|
||||
},
|
||||
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages gone, reload
|
||||
this.loadMessages()
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -89,18 +201,24 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<AboutMailpit />
|
||||
<div class="offcanvas-body pb-0">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
||||
style="overflow-y: auto; overflow-x: hidden;">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavMailbox @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@ import Release from '../components/message/Release.vue'
|
||||
import Screenshot from '../components/message/Screenshot.vue'
|
||||
import { mailbox } from '../stores/mailbox'
|
||||
import { pagination } from '../stores/pagination'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
mixins: [CommonMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@@ -24,20 +28,108 @@ export default {
|
||||
mailbox,
|
||||
pagination,
|
||||
message: false,
|
||||
prevLink: false,
|
||||
nextLink: false,
|
||||
errorMessage: false,
|
||||
apiSideNavURI: false,
|
||||
apiSideNavParams: URLSearchParams,
|
||||
apiIsMore: true,
|
||||
messagesList: [],
|
||||
liveLoaded: 0, // the number new messages prepended tp messageList
|
||||
scrollLoading: false,
|
||||
canLoadMore: true,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.loadMessage()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
const relativeTime = require('dayjs/plugin/relativeTime')
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
this.initLoadMoreAPIParams()
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadMessage()
|
||||
|
||||
this.messagesList = JSON.parse(JSON.stringify(this.mailbox.messages))
|
||||
if (!this.messagesList.length) {
|
||||
this.loadMore()
|
||||
}
|
||||
|
||||
this.refreshUI()
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("new", this.handleWSNew)
|
||||
this.eventBus.on("update", this.handleWSUpdate)
|
||||
this.eventBus.on("delete", this.handleWSDelete)
|
||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// unsubscribe from events
|
||||
this.eventBus.off("new", this.handleWSNew)
|
||||
this.eventBus.off("update", this.handleWSUpdate)
|
||||
this.eventBus.off("delete", this.handleWSDelete)
|
||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
computed: {
|
||||
// get current message read status
|
||||
isRead() {
|
||||
const l = this.messagesList.length
|
||||
if (!this.message || !l) {
|
||||
return true
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = 0; x < l; x++) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return this.messagesList[x].Read
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
// get the previous message ID
|
||||
previousID() {
|
||||
const l = this.messagesList.length
|
||||
if (!this.message || !l) {
|
||||
return false
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = 0; x < l; x++) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return id
|
||||
}
|
||||
id = this.messagesList[x].ID
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
// get the next message ID
|
||||
nextID() {
|
||||
const l = this.messagesList.length
|
||||
if (!this.message || !l) {
|
||||
return false
|
||||
}
|
||||
|
||||
let id = false
|
||||
for (x = l - 1; x > 0; x--) {
|
||||
if (this.messagesList[x].ID == this.message.ID) {
|
||||
return id
|
||||
}
|
||||
id = this.messagesList[x].ID
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -48,9 +140,8 @@ export default {
|
||||
this.errorMessage = false
|
||||
const d = response.data
|
||||
|
||||
if (this.wasUnread(d.ID)) {
|
||||
mailbox.unread--
|
||||
}
|
||||
// update read status in case websockets is not working
|
||||
this.handleWSUpdate({ 'ID': d.ID, Read: true })
|
||||
|
||||
// replace inline images embedded as inline attachments
|
||||
if (d.HTML && d.Inline) {
|
||||
@@ -94,7 +185,9 @@ export default {
|
||||
|
||||
this.message = d
|
||||
|
||||
this.detectPrevNext()
|
||||
this.$nextTick(() => {
|
||||
this.scrollSidebarToCurrent()
|
||||
})
|
||||
},
|
||||
(error) => {
|
||||
this.errorMessage = true
|
||||
@@ -114,37 +207,157 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
// try detect whether this message was unread based on messages listing
|
||||
wasUnread(id) {
|
||||
for (let m in mailbox.messages) {
|
||||
if (mailbox.messages[m].ID == id) {
|
||||
if (!mailbox.messages[m].Read) {
|
||||
mailbox.messages[m].Read = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
// UI refresh ticker to adjust relative times
|
||||
refreshUI() {
|
||||
window.setTimeout(() => {
|
||||
this.$forceUpdate()
|
||||
this.refreshUI()
|
||||
}, 30000)
|
||||
},
|
||||
|
||||
// handler for websocket new messages
|
||||
handleWSNew(data) {
|
||||
// do not add when searching or >= 100 new messages have been received
|
||||
if (this.mailbox.searching || this.liveLoaded >= 100) {
|
||||
return
|
||||
}
|
||||
|
||||
this.liveLoaded++
|
||||
this.messagesList.unshift(data)
|
||||
this.scrollSidebarToCurrent()
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
for (let x = 0; x < this.messagesList.length; x++) {
|
||||
if (this.messagesList[x].ID == data.ID) {
|
||||
// update message
|
||||
this.messagesList[x] = { ...this.messagesList[x], ...data }
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
detectPrevNext() {
|
||||
// generate the prev/next links based on current message list
|
||||
this.prevLink = false
|
||||
this.nextLink = false
|
||||
let found = false
|
||||
|
||||
for (let m in mailbox.messages) {
|
||||
if (mailbox.messages[m].ID == this.message.ID) {
|
||||
found = true
|
||||
} else if (found && !this.nextLink) {
|
||||
this.nextLink = mailbox.messages[m].ID
|
||||
break
|
||||
} else {
|
||||
this.prevLink = mailbox.messages[m].ID
|
||||
// handler for websocket message deletion
|
||||
handleWSDelete(data) {
|
||||
for (let x = 0; x < this.messagesList.length; x++) {
|
||||
if (this.messagesList[x].ID == data.ID) {
|
||||
// remove message from the list
|
||||
this.messagesList.splice(x, 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages gone, go to inbox
|
||||
this.$router.push('/')
|
||||
},
|
||||
|
||||
// return whether the sidebar is visible
|
||||
sidebarVisible() {
|
||||
return this.$refs.MessageList.offsetParent != null
|
||||
},
|
||||
|
||||
// scroll sidenav to current message if found
|
||||
scrollSidebarToCurrent() {
|
||||
const cont = document.getElementById('MessageList')
|
||||
if (!cont) {
|
||||
return
|
||||
}
|
||||
const c = cont.querySelector('.router-link-active')
|
||||
if (c) {
|
||||
const outer = cont.getBoundingClientRect()
|
||||
const li = c.getBoundingClientRect()
|
||||
if (outer.top > li.top || outer.bottom < li.bottom) {
|
||||
c.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
scrollHandler(e) {
|
||||
if (!this.canLoadMore || this.scrollLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
const { scrollTop, offsetHeight, scrollHeight } = e.target
|
||||
if ((scrollTop + offsetHeight + 150) >= scrollHeight) {
|
||||
this.loadMore()
|
||||
}
|
||||
},
|
||||
|
||||
loadMore() {
|
||||
if (this.messagesList.length) {
|
||||
// get last created timestamp
|
||||
const oldest = this.messagesList[this.messagesList.length - 1].Created
|
||||
// if set append `before=<ts>`
|
||||
this.apiSideNavParams.set('before', oldest)
|
||||
}
|
||||
|
||||
this.scrollLoading = true
|
||||
|
||||
this.get(this.apiSideNavURI, this.apiSideNavParams, (response) => {
|
||||
if (response.data.messages.length) {
|
||||
this.messagesList.push(...response.data.messages)
|
||||
} else {
|
||||
this.canLoadMore = false
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.scrollLoading = false
|
||||
})
|
||||
}, null, true)
|
||||
},
|
||||
|
||||
initLoadMoreAPIParams() {
|
||||
let apiURI = this.resolve(`/api/v1/messages`)
|
||||
let p = {}
|
||||
|
||||
if (mailbox.searching) {
|
||||
apiURI = this.resolve(`/api/v1/search`)
|
||||
p.query = mailbox.searching
|
||||
}
|
||||
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
|
||||
this.apiSideNavURI = apiURI
|
||||
|
||||
this.apiSideNavParams = new URLSearchParams(p)
|
||||
},
|
||||
|
||||
getRelativeCreated(message) {
|
||||
const d = new Date(message.Created)
|
||||
return dayjs(d).fromNow()
|
||||
},
|
||||
|
||||
getPrimaryEmailTo(message) {
|
||||
for (let i in message.To) {
|
||||
return message.To[i].Address
|
||||
}
|
||||
|
||||
return '[ Undisclosed recipients ]'
|
||||
},
|
||||
|
||||
isActive(id) {
|
||||
return this.message.ID == id
|
||||
},
|
||||
|
||||
toTagUrl(t) {
|
||||
if (t.match(/ /)) {
|
||||
t = `"${t}"`
|
||||
}
|
||||
const p = {
|
||||
q: 'tag:' + t
|
||||
}
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
return '/search?' + params.toString()
|
||||
},
|
||||
|
||||
downloadMessageBody(str, ext) {
|
||||
const dl = document.createElement('a')
|
||||
dl.href = "data:text/plain," + encodeURIComponent(str)
|
||||
@@ -157,25 +370,44 @@ export default {
|
||||
this.$refs.ScreenshotRef.initScreenshot()
|
||||
},
|
||||
|
||||
// mark current message as read
|
||||
markUnread() {
|
||||
// toggle current message read status
|
||||
toggleRead() {
|
||||
if (!this.message) {
|
||||
return false
|
||||
}
|
||||
const read = !this.isRead
|
||||
|
||||
const ids = [this.message.ID]
|
||||
const uri = this.resolve('/api/v1/messages')
|
||||
this.put(uri, { 'read': false, 'ids': [this.message.ID] }, (response) => {
|
||||
this.goBack()
|
||||
this.put(uri, { 'Read': read, 'IDs': ids }, () => {
|
||||
if (!this.sidebarVisible()) {
|
||||
return this.goBack()
|
||||
}
|
||||
|
||||
// manually update read status in case websockets is not working
|
||||
this.handleWSUpdate({ 'ID': this.message.ID, Read: read })
|
||||
})
|
||||
},
|
||||
|
||||
deleteMessage() {
|
||||
const ids = [this.message.ID]
|
||||
const uri = this.resolve('/api/v1/messages')
|
||||
this.delete(uri, { 'ids': ids }, () => {
|
||||
this.goBack()
|
||||
// calculate next ID before deletion to prevent WS race
|
||||
const goToID = this.nextID ? this.nextID : this.previousID
|
||||
|
||||
this.delete(uri, { 'IDs': ids }, () => {
|
||||
if (!this.sidebarVisible()) {
|
||||
return this.goBack()
|
||||
}
|
||||
if (goToID) {
|
||||
return this.$router.push('/view/' + goToID)
|
||||
}
|
||||
|
||||
return this.goBack()
|
||||
})
|
||||
},
|
||||
|
||||
// return to mailbox or search based on origin
|
||||
goBack() {
|
||||
mailbox.lastMessage = this.$route.params.id
|
||||
|
||||
@@ -189,8 +421,7 @@ export default {
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push('/search?' + params.toString())
|
||||
this.$router.push('/search?' + new URLSearchParams(p).toString())
|
||||
} else {
|
||||
const p = {}
|
||||
if (pagination.start > 0) {
|
||||
@@ -199,11 +430,14 @@ export default {
|
||||
if (pagination.limit != pagination.defaultLimit) {
|
||||
p.limit = pagination.limit.toString()
|
||||
}
|
||||
const params = new URLSearchParams(p)
|
||||
this.$router.push('/?' + params.toString())
|
||||
this.$router.push('/?' + new URLSearchParams(p).toString())
|
||||
}
|
||||
},
|
||||
|
||||
reloadWindow() {
|
||||
location.reload()
|
||||
},
|
||||
|
||||
initReleaseModal() {
|
||||
this.modal('ReleaseModal').show()
|
||||
window.setTimeout(() => {
|
||||
@@ -218,25 +452,27 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-dark row flex-shrink-0 bg-primary text-white">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 col-auto pe-0">
|
||||
<div class="d-none d-xl-block col-xl-3 col-auto pe-0">
|
||||
<RouterLink to="/" class="navbar-brand text-white me-0" @click="pagination.start = 0">
|
||||
<img :src="resolve('/mailpit.svg')" alt="Mailpit">
|
||||
<span class="ms-2 d-none d-sm-inline">Mailpit</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col col-md-4k col-lg-5 col-xl-6" v-if="!errorMessage">
|
||||
<button @click="goBack()" class="btn btn-outline-light me-3 me-sm-4 d-md-none" title="Return to messages">
|
||||
<div class="col col-xl-5" v-if="!errorMessage">
|
||||
<button @click="goBack()" class="btn btn-outline-light me-3 d-xl-none" title="Return to messages">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
<span class="ms-2 d-none d-lg-inline">Back</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="markUnread">
|
||||
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Mark unread" v-on:click="toggleRead()">
|
||||
<i class="bi bi-eye-slash me-md-2" :class="isRead ? 'bi-eye-slash' : 'bi-eye'"></i>
|
||||
<span class="d-none d-md-inline">Mark <template v-if="isRead">un</template>read</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Release message"
|
||||
v-if="mailbox.uiConfig.MessageRelay && mailbox.uiConfig.MessageRelay.Enabled"
|
||||
v-on:click="initReleaseModal">
|
||||
v-on:click="initReleaseModal()">
|
||||
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage">
|
||||
<button class="btn btn-outline-light me-1 me-sm-2" title="Delete message" v-on:click="deleteMessage()">
|
||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -297,19 +533,18 @@ export default {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="'/view/' + prevLink" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||
:class="prevLink ? '' : 'disabled'" title="View previous message">
|
||||
<RouterLink :to="'/view/' + previousID" class="btn btn-outline-light ms-1 ms-sm-2 me-1"
|
||||
:class="previousID ? '' : 'disabled'" title="View previous message">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</RouterLink>
|
||||
<RouterLink :to="'/view/' + nextLink" class="btn btn-outline-light" :class="nextLink ? '' : 'disabled'">
|
||||
<RouterLink :to="'/view/' + nextID" class="btn btn-outline-light" :class="nextID ? '' : 'disabled'">
|
||||
<i class="bi bi-caret-right-fill" title="View next message"></i>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
||||
style="overflow-y: auto; overflow-x: hidden;">
|
||||
<div class="d-none d-xl-flex col-xl-3 h-100 flex-column">
|
||||
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
|
||||
v-if="mailbox.uiConfig.Label">
|
||||
{{ mailbox.uiConfig.Label }}
|
||||
@@ -318,7 +553,11 @@ export default {
|
||||
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
|
||||
<button @click="goBack()" class="list-group-item list-group-item-action">
|
||||
<i class="bi bi-arrow-return-left me-1"></i>
|
||||
<span class="ms-1">Return</span>
|
||||
<span class="ms-1">
|
||||
Return to
|
||||
<template v-if="mailbox.searching">search</template>
|
||||
<template v-else>inbox</template>
|
||||
</span>
|
||||
<span class="badge rounded-pill ms-1 float-end text-bg-secondary" title="Unread messages"
|
||||
v-if="mailbox.unread && !errorMessage">
|
||||
{{ formatNumber(mailbox.unread) }}
|
||||
@@ -326,24 +565,49 @@ export default {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" v-if="!errorMessage">
|
||||
<div class="card-body text-body-secondary small">
|
||||
<p class="card-text">
|
||||
<b>Message date:</b><br>
|
||||
<small>{{ messageDate(message.Date) }}</small>
|
||||
</p>
|
||||
<p class="card-text">
|
||||
<b>Size:</b> {{ getFileSize(message.Size) }}
|
||||
</p>
|
||||
<p class="card-text" v-if="allAttachments(message).length">
|
||||
<b>Attachments:</b> {{ allAttachments(message).length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-y-auto px-1 me-n1" id="MessageList" ref="MessageList"
|
||||
@scroll="scrollHandler">
|
||||
<button v-if="liveLoaded >= 100" class="w-100 alert alert-warning small" @click="reloadWindow()">
|
||||
Reload to see newer messages
|
||||
</button>
|
||||
<template v-if="messagesList && messagesList.length">
|
||||
<div class="list-group">
|
||||
<RouterLink v-for="message in messagesList" :to="'/view/' + message.ID" :key="message.ID"
|
||||
:id="message.ID"
|
||||
class="row gx-1 message d-flex small list-group-item list-group-item-action"
|
||||
:class="message.Read ? 'read' : '', isActive(message.ID) ? 'active' : ''">
|
||||
<div class="col-12 overflow-x-hidden">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div class="col overflow-x-hidden">
|
||||
<div class="text-truncate privacy small">
|
||||
To: {{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{ message.To.length - 1 }}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto small">
|
||||
<i class="bi bi-paperclip h6" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
<div v-if="message.Tags.length" class="col-12">
|
||||
<RouterLink class="badge me-1" v-for="t in message.Tags" :to="toTagUrl(t)"
|
||||
v-on:click="pagination.start = 0"
|
||||
:style="mailbox.showTagColors ? { backgroundColor: colorHash(t) } : { backgroundColor: '#6c757d' }"
|
||||
:title="'Filter messages tagged with ' + t">
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
<div class="col-xl-10 col-md-9 mh-100 ps-0 ps-md-2 pe-0">
|
||||
<div class="col-xl-9 mh-100 ps-0 ps-md-2 pe-0">
|
||||
<div class="mh-100" style="overflow-y: auto;" id="message-page">
|
||||
<template v-if="errorMessage">
|
||||
<h3 class="text-center my-3">
|
||||
|
||||
@@ -14,6 +14,9 @@ import { pagination } from '../stores/pagination'
|
||||
export default {
|
||||
mixins: [CommonMixins, MessagesMixins],
|
||||
|
||||
// global event bus to handle message status changes
|
||||
inject: ["eventBus"],
|
||||
|
||||
components: {
|
||||
AboutMailpit,
|
||||
AjaxLoader,
|
||||
@@ -28,6 +31,7 @@ export default {
|
||||
return {
|
||||
mailbox,
|
||||
pagination,
|
||||
delayedRefresh: false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,6 +44,18 @@ export default {
|
||||
mounted() {
|
||||
mailbox.searching = this.getSearch()
|
||||
this.doSearch()
|
||||
|
||||
// subscribe to events
|
||||
this.eventBus.on("update", this.handleWSUpdate)
|
||||
this.eventBus.on("delete", this.handleWSDelete)
|
||||
this.eventBus.on("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// unsubscribe from events
|
||||
this.eventBus.off("update", this.handleWSUpdate)
|
||||
this.eventBus.off("delete", this.handleWSDelete)
|
||||
this.eventBus.off("truncate", this.handleWSTruncate)
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -59,7 +75,50 @@ export default {
|
||||
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
|
||||
}
|
||||
this.loadMessages()
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message updates
|
||||
handleWSUpdate(data) {
|
||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||
if (this.mailbox.messages[x].ID == data.ID) {
|
||||
// update message
|
||||
this.mailbox.messages[x] = { ...this.mailbox.messages[x], ...data }
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// handler for websocket message deletion
|
||||
handleWSDelete(data) {
|
||||
let removed = 0;
|
||||
for (let x = 0; x < this.mailbox.messages.length; x++) {
|
||||
if (this.mailbox.messages[x].ID == data.ID) {
|
||||
// remove message from the list
|
||||
this.mailbox.messages.splice(x, 1)
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed || this.delayedRefresh) {
|
||||
// nothing changed on this screen, or a refresh is queued, don't refresh
|
||||
return
|
||||
}
|
||||
|
||||
// delayedRefresh prevents unnecessary reloads when multiple messages are deleted
|
||||
this.delayedRefresh = true
|
||||
|
||||
window.setTimeout(() => {
|
||||
this.delayedRefresh = false
|
||||
this.loadMessages()
|
||||
}, 500)
|
||||
},
|
||||
|
||||
// handler for websocket message truncation
|
||||
handleWSTruncate() {
|
||||
// all messages deleted, go back to inbox
|
||||
this.$router.push('/')
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -93,18 +152,23 @@ export default {
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#offcanvas"
|
||||
aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags @loadMessages="loadMessages" />
|
||||
<AboutMailpit />
|
||||
<div class="offcanvas-body pb-0">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
|
||||
style="overflow-y: auto; overflow-x: hidden;">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags @loadMessages="loadMessages" />
|
||||
<div class="d-none d-md-flex h-100 col-xl-2 col-md-3 flex-column">
|
||||
<div class="flex-grow-1 overflow-y-auto">
|
||||
<NavSearch @loadMessages="loadMessages" />
|
||||
<NavTags />
|
||||
</div>
|
||||
<AboutMailpit />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package websockets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
)
|
||||
@@ -83,5 +84,26 @@ func Broadcast(t string, msg interface{}) {
|
||||
return
|
||||
}
|
||||
|
||||
// add a very small delay to prevent broadcasts from being interpreted
|
||||
// as a multi-line messages (eg: storage.DeleteMessages() which can send a very quick series)
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
go func() { MessageHub.Broadcast <- b }()
|
||||
}
|
||||
|
||||
// BroadCastClientError is a wrapper to broadcast client errors to the web UI
|
||||
func BroadCastClientError(severity, errorType, ip, message string) {
|
||||
msg := struct {
|
||||
Level string
|
||||
Type string
|
||||
IP string
|
||||
Message string
|
||||
}{
|
||||
severity,
|
||||
errorType,
|
||||
ip,
|
||||
message,
|
||||
}
|
||||
|
||||
Broadcast("error", msg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user