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

Merge branch 'release/v1.16.0'

This commit is contained in:
Ralph Slooten 2024-04-12 15:19:46 +12:00
commit affe19beb5
44 changed files with 2105 additions and 906 deletions

View File

@ -26,8 +26,8 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: | restore-keys: |
${{ runner.os }}-go- ${{ runner.os }}-go-
- run: go test ./internal/storage ./server ./internal/tools ./internal/html2text -v - run: go test -p 1 ./internal/storage ./server ./internal/tools ./internal/html2text -v
- run: go test ./internal/storage ./internal/html2text -bench=. - run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
# build the assets # build the assets
- name: Build web UI - name: Build web UI

View File

@ -2,6 +2,25 @@
Notable changes to Mailpit will be documented in this file. Notable changes to Mailpit will be documented in this file.
## [v1.16.0]
### Chore
- Update caniemail test database
- Update node dependencies
- Update Go dependencies
- Switch database flag/env to `--database` / `MP_DATABASE`
### Feature
- Search support for before: and after: dates ([#252](https://github.com/axllent/mailpit/issues/252))
- Add optional tenant ID to isolate data in shared databases ([#254](https://github.com/axllent/mailpit/issues/254))
- Option to use rqlite database storage ([#254](https://github.com/axllent/mailpit/issues/254))
### Fix
- Remove duplicated authentication check ([#276](https://github.com/axllent/mailpit/issues/276))
- Prevent conditional JS error when global mailbox tag list is modified via auto/plus-address tagging while viewing a message
- Extract plus addresses from email addresses only, not names
## [v1.15.1] ## [v1.15.1]
### Chore ### Chore

View File

@ -41,7 +41,8 @@ settings to determine the HTTP bind interface & port.
IdleConnTimeout: time.Second * 5, IdleConnTimeout: time.Second * 5,
ExpectContinueTimeout: time.Second * 5, ExpectContinueTimeout: time.Second * 5,
TLSHandshakeTimeout: time.Second * 5, TLSHandshakeTimeout: time.Second * 5,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // do not verify TLS in case this instance is using HTTPS
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec
} }
client := &http.Client{Transport: conf} client := &http.Client{Transport: conf}

View File

@ -19,7 +19,7 @@ If you have several thousand messages in your mailbox, then it is advised to shu
Mailpit while you reindex as this process will likely result in database locking issues.`, Mailpit while you reindex as this process will likely result in database locking issues.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
config.DataFile = args[0] config.Database = args[0]
config.MaxMessages = 0 config.MaxMessages = 0
if err := storage.InitDB(); err != nil { if err := storage.InitDB(); err != nil {

View File

@ -80,7 +80,8 @@ func init() {
// load environment variables // load environment variables
initConfigFromEnv() initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data") rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
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().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates") 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().BoolVar(&config.IgnoreDuplicateIDs, "ignore-duplicate-ids", config.IgnoreDuplicateIDs, "Ignore duplicate messages (by Message-Id)")
@ -132,6 +133,10 @@ func init() {
rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages") rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages")
rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second") rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second")
// DEPRECATED FLAG 2024/04/12 - but will not be removed to maintain backwards compatibility
rootCmd.Flags().StringVar(&config.Database, "db-file", config.Database, "Database file to store persistent data")
rootCmd.Flags().Lookup("db-file").Hidden = true
// DEPRECATED FLAGS 2023/03/12 // DEPRECATED FLAGS 2023/03/12
rootCmd.Flags().StringVar(&config.UITLSCert, "ui-ssl-cert", config.UITLSCert, "SSL certificate for web UI - requires ui-ssl-key") rootCmd.Flags().StringVar(&config.UITLSCert, "ui-ssl-cert", config.UITLSCert, "SSL certificate for web UI - requires ui-ssl-key")
rootCmd.Flags().StringVar(&config.UITLSKey, "ui-ssl-key", config.UITLSKey, "SSL key for web UI - requires ui-ssl-cert") rootCmd.Flags().StringVar(&config.UITLSKey, "ui-ssl-key", config.UITLSKey, "SSL key for web UI - requires ui-ssl-cert")
@ -155,7 +160,12 @@ func init() {
// Load settings from environment // Load settings from environment
func initConfigFromEnv() { func initConfigFromEnv() {
// General // General
config.DataFile = os.Getenv("MP_DATA_FILE") if len(os.Getenv("MP_DATABASE")) > 0 {
config.Database = os.Getenv("MP_DATABASE")
}
config.TenantID = os.Getenv("MP_TENANT_ID")
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 { if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES")) config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
} }
@ -223,7 +233,6 @@ func initConfigFromEnv() {
if getEnabledFromEnv("MP_SMTP_REQUIRE_TLS") { if getEnabledFromEnv("MP_SMTP_REQUIRE_TLS") {
config.SMTPRequireTLS = true config.SMTPRequireTLS = true
} }
if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") { if getEnabledFromEnv("MP_SMTP_AUTH_ALLOW_INSECURE") {
config.SMTPAuthAllowInsecure = true config.SMTPAuthAllowInsecure = true
} }
@ -289,6 +298,11 @@ func initConfigFromEnv() {
// load deprecated settings from environment and warn // load deprecated settings from environment and warn
func initDeprecatedConfigFromEnv() { func initDeprecatedConfigFromEnv() {
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
if len(os.Getenv("MP_DATA_FILE")) > 0 {
config.Database = os.Getenv("MP_DATA_FILE")
}
// deprecated 2023/03/12 // deprecated 2023/03/12
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 { if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT") logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")

View File

@ -26,8 +26,12 @@ var (
// HTTPListen to listen on <interface>:<port> // HTTPListen to listen on <interface>:<port>
HTTPListen = "[::]:8025" HTTPListen = "[::]:8025"
// DataFile for mail (optional) // Database for mail (optional)
DataFile string Database string
// TenantID is an optional prefix to be applied to all database tables,
// allowing multiple isolated instances of Mailpit to share a database.
TenantID = ""
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute) // MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500 MaxMessages = 500
@ -185,8 +189,18 @@ func VerifyConfig() error {
cssFontRestriction, cssFontRestriction, cssFontRestriction, cssFontRestriction,
) )
if DataFile != "" && isDir(DataFile) { if Database != "" && isDir(Database) {
DataFile = filepath.Join(DataFile, "mailpit.db") Database = filepath.Join(Database, "mailpit.db")
}
TenantID = strings.TrimSpace(TenantID)
if TenantID != "" {
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)
TenantID = re.ReplaceAllString(TenantID, "_")
if !strings.HasSuffix(TenantID, "_") {
TenantID = TenantID + "_"
}
} }
re := regexp.MustCompile(`.*:\d+$`) re := regexp.MustCompile(`.*:\d+$`)

21
go.mod
View File

@ -3,37 +3,36 @@ module github.com/axllent/mailpit
go 1.20 go 1.20
require ( require (
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/PuerkitoBio/goquery v1.9.1 github.com/PuerkitoBio/goquery v1.9.1
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/axllent/semver v0.0.1 github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1 github.com/gorilla/websocket v1.5.1
github.com/jhillyerd/enmime v1.2.0 github.com/jhillyerd/enmime v1.2.0
github.com/klauspost/compress v1.17.7 github.com/klauspost/compress v1.17.8
github.com/leporo/sqlf v1.4.0 github.com/leporo/sqlf v1.4.0
github.com/lithammer/shortuuid/v4 v4.0.0 github.com/lithammer/shortuuid/v4 v4.0.0
github.com/mhale/smtpd v0.8.2 github.com/mhale/smtpd v0.8.2
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e
github.com/rqlite/gorqlite v0.0.0-20240227123050-397b03f02418
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.2 github.com/tg123/go-htpasswd v1.2.2
github.com/vanng822/go-premailer v1.20.2 github.com/vanng822/go-premailer v1.20.2
golang.org/x/net v0.22.0 golang.org/x/net v0.24.0
golang.org/x/text v0.14.0 golang.org/x/text v0.14.0
golang.org/x/time v0.5.0 golang.org/x/time v0.5.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.5 modernc.org/sqlite v1.29.6
) )
require ( require (
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cznic/ql v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
@ -53,14 +52,14 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vanng822/css v1.0.1 // indirect github.com/vanng822/css v1.0.1 // indirect
golang.org/x/crypto v0.21.0 // indirect golang.org/x/crypto v0.22.0 // indirect
golang.org/x/image v0.15.0 // indirect golang.org/x/image v0.15.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.19.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.45.0 // indirect modernc.org/libc v1.49.3 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect modernc.org/token v1.1.0 // indirect
) )

70
go.sum
View File

@ -1,41 +1,19 @@
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244 h1:dqzm54OhCqY8RinR/cx+Ppb0y56Ds5I3wwWhx4XybDg=
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E= github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc= github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07 h1:UHFGPvSxX4C4YBApSPvmUfL8tTvWLj2ryqvT9K4Jcuk=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f h1:7uSNgsgcarNk4oiN/nNkO0J7KAjlsF5Yv5Gf/tFdHas=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4 h1:CVAqftqbj+exlab+8KJQrE+kNIVlQfJt58j4GxCMF1s=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00 h1:FHpbUtp2K8X53/b4aFNj4my5n+i3x+CQCZWNuHWH/+E=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4=
github.com/cznic/lldb v1.1.0 h1:AIA+ham6TSJ+XkMe8imQ/g8KPzMUVWAwqUQQdtuMsHs=
github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak=
github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -43,15 +21,12 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 h1:4gjrh/PN2MuWCCElk8/I4OCKRKWCCo2zEct3VKCbibU=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k= github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -71,8 +46,8 @@ github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQykt
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v1.2.0 h1:dIu1IPEymQgoT2dzuB//ttA/xcV40NMPpQtmd4wslHk= github.com/jhillyerd/enmime v1.2.0 h1:dIu1IPEymQgoT2dzuB//ttA/xcV40NMPpQtmd4wslHk=
github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I= github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYhopv5eyk4I=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@ -87,6 +62,7 @@ github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQ
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.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 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
@ -107,12 +83,16 @@ github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e h1:quuzZLi72kkJjl
github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg= github.com/reiver/go-telnet v0.0.0-20180421082511-9ff0b2ab096e/go.mod h1:+5vNVvEWwEIx86DB9Ke/+a5wBI464eDRo3eF0LcfpWg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
@ -144,8 +124,8 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
@ -161,8 +141,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -177,8 +157,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -206,22 +186,22 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.19.3 h1:vE9kmJqUcyvNOf8F2Hn8od14SOMq34BiqcZ2tMzLk5c= modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/ccgo/v4 v4.11.0 h1:2uc2kRvZLC/oHylsrirRW6f1I4wljQST2BBbm+aKiXM= modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8= 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/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.45.0 h1:qmAJZf9tYFqK/SFSFqpBc9uHWGsvoYWtRcMQdG+JEfM= modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
modernc.org/libc v1.45.0/go.mod h1:YkRHLoN4L70OdO1cVmM83KZhRbRvsc3XogfVzbTXBwE= modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE= modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4=
modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@ -1,6 +1,6 @@
{ {
"api_version":"1.0.4", "api_version":"1.0.4",
"last_update_date":"2024-03-09 07:21:32 +0000", "last_update_date":"2024-04-03 12:33:14 +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"}}, "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":[ "data":[
{ {
@ -110,7 +110,7 @@
"last_test_date":"2023-12-19", "last_test_date":"2023-12-19",
"test_url":"https://www.caniemail.com/tests/css-font-face.html", "test_url":"https://www.caniemail.com/tests/css-font-face.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/veY9MhuhgFeF1ly5crrhTXawfLJSwxgpYi27OElI7iSoc/list", "test_results_url":"https://app.emailonacid.com/app/acidtest/veY9MhuhgFeF1ly5crrhTXawfLJSwxgpYi27OElI7iSoc/list",
"stats":{"apple-mail":{"macos":{"12.2":"y"},"ios":{"10.3":"y","12.3.1":"y"}},"gmail":{"desktop-webmail":{"2019-07":"n #6"},"ios":{"2019-07":"n"},"android":{"2019-07":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-05":"a #2","2021-03":"n #7"},"ios":{"2019-07":"y"},"android":{"2019-07":"a #1"}},"outlook":{"windows":{"2003":"a #3","2007":"a #4 #5","2010":"a #4 #5","2013":"a #4 #5","2016":"a #4 #5","2019":"a #4"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.80":"n"},"outlook-com":{"2019-07":"n","2023-12":"n"},"ios":{"2.51.1":"y","3.29.0":"n"},"android":{"2019-07":"n"}},"samsung-email":{"android":{"6.0":"y #8","2021-11":"y #8"}},"sfr":{"desktop-webmail":{"2019-07":"a #2"},"ios":{"2019-07":"n"},"android":{"2019-07":"n"}},"thunderbird":{"macos":{"60.7":"y","78.5":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-07":"n"},"ios":{"2019-07":"n"},"android":{"2019-07":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"a #2"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"n"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"n"}}}, "stats":{"apple-mail":{"macos":{"12.2":"y"},"ios":{"10.3":"y","12.3.1":"y"}},"gmail":{"desktop-webmail":{"2019-07":"n #6"},"ios":{"2019-07":"n"},"android":{"2019-07":"n"},"mobile-webmail":{"2020-02":"n"}},"orange":{"desktop-webmail":{"2019-05":"a #2","2021-03":"n #7"},"ios":{"2019-07":"y","2024-03":"n"},"android":{"2019-07":"a #1"}},"outlook":{"windows":{"2003":"a #3","2007":"a #4 #5","2010":"a #4 #5","2013":"a #4 #5","2016":"a #4 #5","2019":"a #4"},"windows-mail":{"2020-01":"n"},"macos":{"2011":"y","2016":"y","16.80":"n"},"outlook-com":{"2019-07":"n","2023-12":"n"},"ios":{"2.51.1":"y","3.29.0":"n"},"android":{"2019-07":"n"}},"samsung-email":{"android":{"6.0":"y #8","2021-11":"y #8"}},"sfr":{"desktop-webmail":{"2019-07":"a #2"},"ios":{"2019-07":"n"},"android":{"2019-07":"n"}},"thunderbird":{"macos":{"60.7":"y","78.5":"y"}},"aol":{"desktop-webmail":{"2020-01":"n"},"ios":{"2020-01":"n"},"android":{"2020-01":"n"}},"yahoo":{"desktop-webmail":{"2019-07":"n"},"ios":{"2019-07":"n"},"android":{"2019-07":"n"}},"protonmail":{"desktop-webmail":{"2020-03":"n"},"ios":{"2020-03":"n"},"android":{"2020-03":"y"}},"hey":{"desktop-webmail":{"2020-06":"y"}},"mail-ru":{"desktop-webmail":{"2020-10":"n"}},"fastmail":{"desktop-webmail":{"2021-07":"n"}},"laposte":{"desktop-webmail":{"2021-08":"a #2"}},"gmx":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"n"}},"web-de":{"desktop-webmail":{"2022-06":"n"},"ios":{"2022-06":"y"},"android":{"2022-06":"n"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"y"},"android":{"2022-06":"n"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Partial. Only supported through a `<link>` tag.","2":"Partial. Only supported directly through a `<style>` tag.","3":"Buggy. Support depends on the version of IE installed.","4":"Partial. The declaration is supported but distant fonts are ignored.","5":"Buggy. Elements using a font declared with `@font-face` ignore the font stack and fall back to Times New Roman. Use `mso-generic-font-family` and `mso-font-alt` to control the fallback.","6":"Not supported. Roboto and Google Sans can be used, but only because they're embedded with the webmail's own styles.","7":"Not supported. The `@font-face` declaration is kept but the `src` property is removed.","8":"Not supported when using a Microsoft email address; outlook, live, hotmail, etc.."} "notes_by_num":{"1":"Partial. Only supported through a `<link>` tag.","2":"Partial. Only supported directly through a `<style>` tag.","3":"Buggy. Support depends on the version of IE installed.","4":"Partial. The declaration is supported but distant fonts are ignored.","5":"Buggy. Elements using a font declared with `@font-face` ignore the font stack and fall back to Times New Roman. Use `mso-generic-font-family` and `mso-font-alt` to control the fallback.","6":"Not supported. Roboto and Google Sans can be used, but only because they're embedded with the webmail's own styles.","7":"Not supported. The `@font-face` declaration is kept but the `src` property is removed.","8":"Not supported when using a Microsoft email address; outlook, live, hotmail, etc.."}
}, },
@ -174,7 +174,7 @@
"last_test_date":"2022-08-29", "last_test_date":"2022-08-29",
"test_url":"https://www.caniemail.com/tests/css-media-hover.html", "test_url":"https://www.caniemail.com/tests/css-media-hover.html",
"test_results_url":"https://testi.at/proj/onECpNVH8Dhv7BSLPXUbQ4s0O", "test_results_url":"https://testi.at/proj/onECpNVH8Dhv7BSLPXUbQ4s0O",
"stats":{"apple-mail":{"macos":{"2022-08":"y"},"ios":{"2022-08":"y"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"n"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2022-08":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"samsung-email":{"android":{"10":"y","11":"y"}},"sfr":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"thunderbird":{"macos":{"2022-08":"u"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}},"laposte":{"desktop-webmail":{"2022-08":"u"}}}, "stats":{"apple-mail":{"macos":{"2022-08":"y"},"ios":{"2022-08":"y"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"n"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2022-08":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"samsung-email":{"android":{"10":"y","11":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"thunderbird":{"macos":{"2022-08":"u"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}},"laposte":{"desktop-webmail":{"2022-08":"u"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -259,6 +259,22 @@
"notes_by_num":{"1":"Properties not supported by the client are replaced by `_filtered_a` inside the parenthesis.","2":"Not supported. `@supports` is transformed into `@media not all`."} "notes_by_num":{"1":"Properties not supported by the client are replaced by `_filtered_a` inside the parenthesis.","2":"Not supported. `@supports` is transformed into `@media not all`."}
}, },
{
"slug":"css-backdrop-filter",
"title":"backdrop-filter",
"description":"Lets you apply graphical effects such as blurring or color shifting to the area behind an element.",
"url":"https://www.caniemail.com/features/css-backdrop-filter/",
"category":"css",
"tags":[],
"keywords":"filter",
"last_test_date":"2024-01-17",
"test_url":"https://www.caniemail.com/tests/css-backdrop-filter.html",
"test_results_url":"https://testi.at/proj/p4r7t9n30o7nh7vvfpn",
"stats":{"apple-mail":{"macos":{"10":"y #1","11":"u","12":"u","13":"y #1"},"ios":{"11":"n","12":"n","13":"y #1","14":"y #1","15":"y #1"}},"gmail":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"},"mobile-webmail":{"2024-01":"n"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"n"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-01":"n"},"macos":{"2024-01":"y"},"outlook-com":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"yahoo":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"aol":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"samsung-email":{"android":{"2024-01":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2024-01":"u"}},"protonmail":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"hey":{"desktop-webmail":{"2024-01":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"y"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"gmx":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"web-de":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-01":"u"},"android":{"2024-01":"u"}}},
"notes":null,
"notes_by_num":{"1":"Works with prefix `-webkit`"}
},
{ {
"slug":"css-background-blend-mode", "slug":"css-background-blend-mode",
"title":"background-blend-mode", "title":"background-blend-mode",
@ -430,7 +446,7 @@
"last_test_date":"2023-12-20", "last_test_date":"2023-12-20",
"test_url":"https://www.caniemail.com/tests/css-border-collapse.html", "test_url":"https://www.caniemail.com/tests/css-border-collapse.html",
"test_results_url":"https://testi.at/proj/4zk4fe7tv86fn4bc6", "test_results_url":"https://testi.at/proj/4zk4fe7tv86fn4bc6",
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"2023-12":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2023-12":"y"},"windows-mail":{"2023-12":"y"},"macos":{"2023-12":"y"},"outlook-com":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}}, "stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"2023-12":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2023-12":"y"},"windows-mail":{"2023-12":"y"},"macos":{"2023-12":"y"},"outlook-com":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -462,7 +478,7 @@
"last_test_date":"2022-07-14", "last_test_date":"2022-07-14",
"test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html", "test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html",
"test_results_url":"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY", "test_results_url":"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY",
"stats":{"apple-mail":{"macos":{"10.12":"n","10.13":"n","10.15":"a #1","11":"y","12":"y"},"ios":{"11":"n","12":"a #1","13":"a #1","14":"a #1","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"a #1","2016":"a #1","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"a #1","11":"a #1"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}}, "stats":{"apple-mail":{"macos":{"10.12":"n","10.13":"n","10.15":"a #1","11":"y","12":"y"},"ios":{"11":"n","12":"a #1","13":"a #1","14":"a #1","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"a #1","2016":"a #1","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"a #1","11":"a #1"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Partial. `border-<inline/block>-color` and `border-<inline/block>-width` does not work."} "notes_by_num":{"1":"Partial. `border-<inline/block>-color` and `border-<inline/block>-width` does not work."}
}, },
@ -478,7 +494,7 @@
"last_test_date":"2022-07-14", "last_test_date":"2022-07-14",
"test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html", "test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html",
"test_results_url":"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY", "test_results_url":"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY",
"stats":{"apple-mail":{"macos":{"10.12":"n","10.13":"n","10.15":"y","11":"y","12":"y"},"ios":{"11":"n","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"y","2016":"y","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"y","11":"y"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}}, "stats":{"apple-mail":{"macos":{"10.12":"n","10.13":"n","10.15":"y","11":"y","12":"y"},"ios":{"11":"n","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"y","2016":"y","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"y","11":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -494,7 +510,7 @@
"last_test_date":"2022-07-13", "last_test_date":"2022-07-13",
"test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html", "test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html",
"test_results_url":"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY", "test_results_url":"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY",
"stats":{"apple-mail":{"macos":{"10.15":"n","11":"y","12":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}}, "stats":{"apple-mail":{"macos":{"10.15":"n","11":"y","12":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"},"mobile-webmail":{"2022-07":"n"}},"orange":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-07":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"yahoo":{"desktop-webmail":{"2022-07":"n"},"ios":{"2022-07":"n"},"android":{"2022-07":"n"}},"protonmail":{"desktop-webmail":{"2022-07":"u"},"ios":{"2022-07":"u"},"android":{"2022-07":"u"}},"hey":{"desktop-webmail":{"2022-07":"u"}},"mail-ru":{"desktop-webmail":{"2022-07":"y"}},"fastmail":{"desktop-webmail":{"2022-07":"u"}},"laposte":{"desktop-webmail":{"2022-07":"u"}},"gmx":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"web-de":{"desktop-webmail":{"2022-09":"n"},"ios":{"2022-09":"y"},"android":{"2022-09":"y"}},"ionos-1and1":{"desktop-webmail":{"2022-09":"y"},"android":{"2022-09":"y"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -510,7 +526,7 @@
"last_test_date":"2022-08-16", "last_test_date":"2022-08-16",
"test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html", "test_url":"https://www.caniemail.com/tests/css-border-logical-properties.html",
"test_results_url":"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY", "test_results_url":"https://testi.at/proj/1yxFDAGtwrUmbf4tzMTY",
"stats":{"apple-mail":{"macos":{"10.15":"n","11":"n","12":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"n"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}},"laposte":{"desktop-webmail":{"2022-08":"u"}}}, "stats":{"apple-mail":{"macos":{"10.15":"n","11":"n","12":"y"},"ios":{"13":"n","14":"n","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"n"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2011":"n","2016":"n","16.80":"n"},"outlook-com":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"samsung-email":{"android":{"10":"n","11":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"60.3":"u"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}},"laposte":{"desktop-webmail":{"2022-08":"u"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -542,7 +558,7 @@
"last_test_date":"2023-12-20", "last_test_date":"2023-12-20",
"test_url":"https://www.caniemail.com/tests/css-border-spacing.html", "test_url":"https://www.caniemail.com/tests/css-border-spacing.html",
"test_results_url":"https://testi.at/proj/dyodfk8c5dhjanflz", "test_results_url":"https://testi.at/proj/dyodfk8c5dhjanflz",
"stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"2023-12":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"n"},"outlook-com":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}}, "stats":{"apple-mail":{"macos":{"2023-12":"y"},"ios":{"2023-12":"y"}},"gmail":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"},"mobile-webmail":{"2023-12":"y"}},"orange":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2023-12":"n"},"macos":{"2023-12":"n"},"outlook-com":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"yahoo":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"aol":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"y"},"android":{"2023-12":"y"}},"samsung-email":{"android":{"2023-12":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2023-12":"y"}},"protonmail":{"desktop-webmail":{"2023-12":"u"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"hey":{"desktop-webmail":{"2023-12":"u"}},"mail-ru":{"desktop-webmail":{"2023-12":"y"}},"fastmail":{"desktop-webmail":{"2023-12":"u"}},"laposte":{"desktop-webmail":{"2023-12":"u"}},"gmx":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"web-de":{"desktop-webmail":{"2023-12":"y"},"ios":{"2023-12":"u"},"android":{"2023-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2023-12":"u"},"android":{"2023-12":"u"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -638,9 +654,9 @@
"last_test_date":"2023-09-18", "last_test_date":"2023-09-18",
"test_url":"https://www.caniemail.com/tests/css-color-scheme.html", "test_url":"https://www.caniemail.com/tests/css-color-scheme.html",
"test_results_url":"", "test_results_url":"",
"stats":{"apple-mail":{"macos":{"16.0":"y #1"},"ios":{"16.1":"y #1"}},"gmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"},"mobile-webmail":{"2023-09":"n"}},"orange":{"desktop-webmail":{"2022-12":"u"},"ios":{"2022-12":"u"},"android":{"2022-12":"u"}},"outlook":{"windows":{"2003":"u","2007":"u","2010":"u","2013":"u","2016":"n","2019":"n"},"windows-mail":{"2020-01":"u"},"macos":{"16.73":"n","16.80":"n"},"outlook-com":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"samsung-email":{"android":{"6.1":"y"}},"sfr":{"desktop-webmail":{"2022-12":"u"},"ios":{"2022-12":"u"},"android":{"2022-12":"u"}},"thunderbird":{"macos":{"102.11":"y"}},"aol":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"yahoo":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"protonmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"hey":{"desktop-webmail":{"2023-09":"n"}},"mail-ru":{"desktop-webmail":{"2023-09":"n"}},"fastmail":{"desktop-webmail":{"2023-09":"n"}},"laposte":{"desktop-webmail":{"2022-12":"u"}},"free-fr":{"desktop-webmail":{"2022-12":"u"}},"t-online-de":{"desktop-webmail":{"2023-09":"n"}},"gmx":{"desktop-webmail":{"2023-09":"n"},"ios":{"2022-06":"u"},"android":{"2022-06":"u"}},"web-de":{"desktop-webmail":{"2023-09":"n"},"ios":{"2022-06":"u"},"android":{"2022-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"u"},"android":{"2022-06":"u"}}}, "stats":{"apple-mail":{"macos":{"16.0":"y #1"},"ios":{"16.1":"y #1"}},"gmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"},"mobile-webmail":{"2023-09":"n"}},"orange":{"desktop-webmail":{"2022-12":"u"},"ios":{"2022-12":"u"},"android":{"2022-12":"u"}},"outlook":{"windows":{"2003":"u","2007":"u","2010":"u","2013":"u","2016":"n","2019":"n"},"windows-mail":{"2020-01":"u"},"macos":{"16.73":"n","16.80":"n"},"outlook-com":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"samsung-email":{"android":{"6.1":"y"}},"sfr":{"desktop-webmail":{"2024-03":"a #2"},"ios":{"2024-03":"n"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"102.11":"y"}},"aol":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"yahoo":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"protonmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"hey":{"desktop-webmail":{"2023-09":"n"}},"mail-ru":{"desktop-webmail":{"2023-09":"n"}},"fastmail":{"desktop-webmail":{"2023-09":"n"}},"laposte":{"desktop-webmail":{"2022-12":"u"}},"free-fr":{"desktop-webmail":{"2022-12":"u"}},"t-online-de":{"desktop-webmail":{"2023-09":"n"}},"gmx":{"desktop-webmail":{"2023-09":"n"},"ios":{"2022-06":"u"},"android":{"2022-06":"u"}},"web-de":{"desktop-webmail":{"2023-09":"n"},"ios":{"2022-06":"u"},"android":{"2022-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"u"},"android":{"2022-06":"u"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Works only the html/root element"} "notes_by_num":{"1":"Works only the html/root element","2":"Buggy. The property is supported but does not work due to a prefix added by the webmail on the rule selector."}
}, },
{ {
@ -670,7 +686,7 @@
"last_test_date":"2022-08-02", "last_test_date":"2022-08-02",
"test_url":"https://www.caniemail.com/tests/css-column-layout.html", "test_url":"https://www.caniemail.com/tests/css-column-layout.html",
"test_results_url":"https://testi.at/proj/E87UgpgtlXxt6Rsx4Ec1pcxm", "test_results_url":"https://testi.at/proj/E87UgpgtlXxt6Rsx4Ec1pcxm",
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"samsung-email":{"android":{"2022-08":"y"}},"sfr":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"thunderbird":{"macos":{"102.7":"y"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2023-01":"n"},"ios":{"2022-08":"n","2023-01":"n"},"android":{"2022-08":"n"}},"protonmail":{"desktop-webmail":{"2023-01":"y"},"ios":{"2023-01":"y"},"android":{"2023-01":"y"}},"hey":{"desktop-webmail":{"2023-01":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2023-01":"y"}},"laposte":{"desktop-webmail":{"2022-08":"u"}}}, "stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"}},"samsung-email":{"android":{"2022-08":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"102.7":"y"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2023-01":"n"},"ios":{"2022-08":"n","2023-01":"n"},"android":{"2022-08":"n"}},"protonmail":{"desktop-webmail":{"2023-01":"y"},"ios":{"2023-01":"y"},"android":{"2023-01":"y"}},"hey":{"desktop-webmail":{"2023-01":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2023-01":"y"}},"laposte":{"desktop-webmail":{"2022-08":"u"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -846,11 +862,27 @@
"last_test_date":"2022-08-01", "last_test_date":"2022-08-01",
"test_url":"https://www.caniemail.com/tests/css-font-kerning.html", "test_url":"https://www.caniemail.com/tests/css-font-kerning.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/RlRYNGDjVNBhofxCNxloUcRbUVWGDhJ2kZ4fy6HXpEatH/list", "test_results_url":"https://app.emailonacid.com/app/acidtest/RlRYNGDjVNBhofxCNxloUcRbUVWGDhJ2kZ4fy6HXpEatH/list",
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2022-08":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"}},"t-online-de":{"desktop-webmail":{"2022-08":"n"}}}, "stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2022-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"y","16.80":"y"},"outlook-com":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"4.2101.1":"y"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.31.2":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"n"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"gmx":{"desktop-webmail":{"2022-08":"n"}},"t-online-de":{"desktop-webmail":{"2022-08":"n"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
{
"slug":"css-font-size",
"title":"font-size",
"description":"Sets the size of the font.",
"url":"https://www.caniemail.com/features/css-font-size/",
"category":"css",
"tags":[],
"keywords":"font,size",
"last_test_date":"2024-02-28",
"test_url":"https://www.caniemail.com/tests/css-font-size.html",
"test_results_url":"https://testi.at/proj/vr3ai85bunngsxjjfd2",
"stats":{"apple-mail":{"macos":{"11":"y","12":"y","13":"y","14":"y","15":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"},"mobile-webmail":{"2024-02":"y"}},"orange":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"outlook":{"windows":{"2013":"a #1","2016":"a #1","2019":"a #1","2021":"a #1"},"windows-mail":{"2024-02":"a #2"},"macos":{"2024-02":"y"},"outlook-com":{"2024-02":"y"},"ios":{"2024-02":"y"},"android":{"2024-02":"y"}},"yahoo":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"aol":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"a #1"},"android":{"2024-02":"a #1"}},"samsung-email":{"android":{"2024-02":"a #2"}},"sfr":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"thunderbird":{"macos":{"2024-02":"y"}},"protonmail":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"hey":{"desktop-webmail":{"2024-02":"u"}},"mail-ru":{"desktop-webmail":{"2024-02":"y"}},"fastmail":{"desktop-webmail":{"2024-02":"u"}},"laposte":{"desktop-webmail":{"2024-02":"u"}},"gmx":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"web-de":{"desktop-webmail":{"2024-02":"a #1"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-02":"u"},"android":{"2024-02":"u"}}},
"notes":null,
"notes_by_num":{"1":"Partial support. `rem` values are not supported.","2":"Partial support. `relative` and `percentage` size values not supported."}
},
{ {
"slug":"css-font-stretch", "slug":"css-font-stretch",
"title":"font-stretch", "title":"font-stretch",
@ -862,7 +894,7 @@
"last_test_date":"2023-09-23", "last_test_date":"2023-09-23",
"test_url":"https://www.caniemail.com/tests/css-font-stretch.html", "test_url":"https://www.caniemail.com/tests/css-font-stretch.html",
"test_results_url":"", "test_results_url":"",
"stats":{"apple-mail":{"macos":{"16":"y"},"ios":{"16":"y"}},"gmail":{"desktop-webmail":{"2023-09":"y #1"},"ios":{"2023-09":"y"},"android":{"2023-09":"u"},"mobile-webmail":{"2023-09":"y #1"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2023-09":"y","16.80":"y"},"outlook-com":{"2023-09":"y #1"},"ios":{"2023-09":"y"},"android":{"4.2101.1":"u"}},"yahoo":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2019-09":"u"}},"aol":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"}},"protonmail":{"desktop-webmail":{"2023-09":"y #1"},"ios":{"2023-09":"y"},"android":{"2022-12":"u"}},"thunderbird":{"macos":{"2023-09":"y"}},"samsung-email":{"android":{"6.1.31.2":"u","6.1.90.16":"n"}},"mail-ru":{"desktop-webmail":{"2024-01":"y"}},"free-fr":{"desktop-webmail":{"2022-08":"u"}},"gmx":{"desktop-webmail":{"2022-08":"u"}},"t-online-de":{"desktop-webmail":{"2022-08":"u"}}}, "stats":{"apple-mail":{"macos":{"16":"y"},"ios":{"16":"y"}},"gmail":{"desktop-webmail":{"2023-09":"y #1"},"ios":{"2023-09":"y"},"android":{"2024-03":"n"},"mobile-webmail":{"2023-09":"y #1"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2023-09":"y","16.80":"y"},"outlook-com":{"2023-09":"y #1"},"ios":{"2023-09":"y"},"android":{"2024-03":"n"}},"yahoo":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"}},"protonmail":{"desktop-webmail":{"2023-09":"y #1"},"ios":{"2023-09":"y"},"android":{"2022-12":"u"}},"thunderbird":{"macos":{"2023-09":"y"}},"samsung-email":{"android":{"6.1.31.2":"u","6.1.90.16":"n"}},"mail-ru":{"desktop-webmail":{"2024-01":"y"}},"free-fr":{"desktop-webmail":{"2022-08":"u"}},"gmx":{"desktop-webmail":{"2022-08":"u"}},"t-online-de":{"desktop-webmail":{"2022-08":"u"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"n"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Partial. Depends on the browser. There is currently a bug in Chrome/Edge where font-stretch does not work for variable fonts."} "notes_by_num":{"1":"Partial. Depends on the browser. There is currently a bug in Chrome/Edge where font-stretch does not work for variable fonts."}
}, },
@ -1006,7 +1038,7 @@
"last_test_date":"2022-08-03", "last_test_date":"2022-08-03",
"test_url":"https://www.caniemail.com/tests/css-hyphen.html", "test_url":"https://www.caniemail.com/tests/css-hyphen.html",
"test_results_url":"https://testi.at/proj/rvEUZkBsPVNSbRohvoRigyRiM", "test_results_url":"https://testi.at/proj/rvEUZkBsPVNSbRohvoRigyRiM",
"stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"u"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2022-08":"y","16.80":"n"},"outlook-com":{"2022-08":"n","2023-12":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"samsung-email":{"android":{"2022-08":"y"}},"sfr":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}}}, "stats":{"apple-mail":{"macos":{"16":"y","17":"y","18":"y","19":"y","20":"y","21":"y"},"ios":{"11":"y","12":"y","13":"y","14":"y","15":"y"}},"gmail":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"},"mobile-webmail":{"2022-08":"u"}},"orange":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2022-08":"n"},"macos":{"2022-08":"y","16.80":"n"},"outlook-com":{"2022-08":"n","2023-12":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"samsung-email":{"android":{"2022-08":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"protonmail":{"desktop-webmail":{"2022-08":"u"},"ios":{"2022-08":"u"},"android":{"2022-08":"u"}},"hey":{"desktop-webmail":{"2022-08":"u"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}},"fastmail":{"desktop-webmail":{"2022-08":"u"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -1422,7 +1454,7 @@
"last_test_date":"2023-08-31", "last_test_date":"2023-08-31",
"test_url":"https://www.caniemail.com/tests/css-nesting.html", "test_url":"https://www.caniemail.com/tests/css-nesting.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/8z9ecWkyaSHebmYl0r6dlWFfcia0VNfeKu6s01l5Fw3M0/list", "test_results_url":"https://app.emailonacid.com/app/acidtest/8z9ecWkyaSHebmYl0r6dlWFfcia0VNfeKu6s01l5Fw3M0/list",
"stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1","17.2":"y"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1","16.80":"n"},"outlook-com":{"2023-08":"n","2024-01":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"samsung-email":{"android":{"6.0":"u","6.1.90.16":"a #4"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n"}},"aol":{"desktop-webmail":{"2024-01":"n #3"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2023-08":"u"}},"protonmail":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}}, "stats":{"apple-mail":{"macos":{"16.0":"a #1"},"ios":{"16.6":"a #1","17.2":"y"}},"gmail":{"desktop-webmail":{"2023-08":"n"},"ios":{"2023-08":"n"},"android":{"2023-08":"n"},"mobile-webmail":{"2023-08":"n"}},"orange":{"desktop-webmail":{"2023-08":"a #2"},"ios":{"2023-08":"a #2"},"android":{"2023-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-08":"n"},"macos":{"16.78":"a #1","16.80":"n"},"outlook-com":{"2023-08":"n","2024-01":"n"},"ios":{"2023-08":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"6.0":"u","6.1.90.16":"a #4"}},"sfr":{"desktop-webmail":{"2023-08":"a #1 #2"},"ios":{"2023-08":"n"},"android":{"2023-08":"u"}},"thunderbird":{"macos":{"102.15":"n"}},"aol":{"desktop-webmail":{"2024-01":"n #3"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"yahoo":{"desktop-webmail":{"2023-08":"n #3"},"ios":{"2023-08":"n #3"},"android":{"2024-03":"n #3"}},"protonmail":{"desktop-webmail":{"2023-08":"u"},"ios":{"2023-08":"u"},"android":{"2023-08":"u"}},"hey":{"desktop-webmail":{"2023-08":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2023-08":"u"}},"laposte":{"desktop-webmail":{"2023-08":"u"}},"free-fr":{"desktop-webmail":{"2023-08":"u"}},"t-online-de":{"desktop-webmail":{"2023-08":"u"}},"gmx":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"web-de":{"desktop-webmail":{"2021-12":"u"},"ios":{"2021-12":"u"},"android":{"2021-12":"u"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"u"},"android":{"2021-12":"u"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Partial. `E { F {}}` doesn’t work, but `E { & F {}}` does. Full support was added in macOS 14.2.","2":"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.","3":"Not supported. The nested selectors are removed, making the nested properties apply to the parent selector.","4":"Partial. Not supported with Hotmail/Outlook accounts."} "notes_by_num":{"1":"Partial. `E { F {}}` doesn’t work, but `E { & F {}}` does. Full support was added in macOS 14.2.","2":"Buggy. The syntax is supported, but nested selectors are prefixed by the webmail, which might invalidate the selector.","3":"Not supported. The nested selectors are removed, making the nested properties apply to the parent selector.","4":"Partial. Not supported with Hotmail/Outlook accounts."}
}, },
@ -2051,6 +2083,22 @@
"notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradientRadial\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill)."} "notes_by_num":{"1":"Gradients can be created in VML using `type=\"gradientRadial\"` on a `<v:fill>`. See [VML documentation](https://docs.microsoft.com/en-us/windows/win32/vml/web-workshop---how-to-use-vml-on-web-pages-----fill--element#gradient-fill)."}
}, },
{
"slug":"css-resize",
"title":"resize",
"description":"Sets whether an element is resizable, and in which directions.",
"url":"https://www.caniemail.com/features/css-resize/",
"category":"css",
"tags":[],
"keywords":"textarea",
"last_test_date":"2024-01-17",
"test_url":"https://www.caniemail.com/tests/css-resize.html",
"test_results_url":"https://testi.at/proj/6vd212zjibljibxkt9",
"stats":{"apple-mail":{"macos":{"2024-01":"a #2"},"ios":{"2024-01":"n"}},"gmail":{"desktop-webmail":{"2024-01":"n #1"},"ios":{"2024-01":"n #1"},"android":{"2024-01":"n"},"mobile-webmail":{"2024-01":"u"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"n"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-01":"n"},"macos":{"2024-01":"n #1"},"outlook-com":{"2024-01":"n #1"},"ios":{"2024-01":"n #1"},"android":{"2024-01":"n"}},"yahoo":{"desktop-webmail":{"2024-01":"n #1"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"aol":{"desktop-webmail":{"2024-01":"n #1"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"samsung-email":{"android":{"2024-01":"y"}},"sfr":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"n"},"android":{"2024-01":"u"}},"thunderbird":{"macos":{"2024-01":"u"}},"protonmail":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"n"},"android":{"2024-01":"u"}},"hey":{"desktop-webmail":{"2024-01":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"y"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"gmx":{"desktop-webmail":{"2024-01":"n #1"},"ios":{"2024-01":"n"},"android":{"2024-01":"u"}},"web-de":{"desktop-webmail":{"2024-01":"n #1"},"ios":{"2024-01":"n"},"android":{"2024-01":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-01":"u"},"android":{"2024-01":"u"}}},
"notes":null,
"notes_by_num":{"1":"`resize` property is stripped from style tag","2":"Does not support `inline` and `block` values"}
},
{ {
"slug":"css-rgb", "slug":"css-rgb",
"title":"rgb()", "title":"rgb()",
@ -2275,6 +2323,38 @@
"notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.","3":"Not supported. The selector is removed and left prefixed with the outer most parent element."} "notes_by_num":{"1":"Partial. Not supported with non Google accounts.","2":"Buggy. The first `<head>` in the HTML is removed, so `<style>` elements need to be in a second `<head>` element.","3":"Not supported. The selector is removed and left prefixed with the outer most parent element."}
}, },
{
"slug":"css-shape-margin",
"title":"shape-margin",
"description":"Sets a margin for a CSS shape created using `shape-outside`",
"url":"https://www.caniemail.com/features/css-shape-margin/",
"category":"css",
"tags":[],
"keywords":"shape, margin",
"last_test_date":"2024-03-13",
"test_url":"https://www.caniemail.com/tests/css-shape-margin.html",
"test_results_url":"https://testi.at/proj/l2xyho58cl2yfnknhb",
"stats":{"apple-mail":{"macos":{"2024-03":"y"},"ios":{"2024-03":"y"}},"gmail":{"desktop-webmail":{"2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"},"mobile-webmail":{"2024-03":"n"}},"orange":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-03":"n"},"macos":{"16.56":"y"},"outlook-com":{"2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"2024-03":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"thunderbird":{"macos":{"2024-03":"y"}},"aol":{"desktop-webmail":{"2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"yahoo":{"desktop-webmail":{"2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"protonmail":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"hey":{"desktop-webmail":{"2024-03":"u"}},"mail-ru":{"desktop-webmail":{"2024-03":"y"}},"fastmail":{"desktop-webmail":{"2024-03":"u"}},"laposte":{"desktop-webmail":{"2024-03":"u"}}},
"notes":null,
"notes_by_num":null
},
{
"slug":"css-shape-outside",
"title":"shape-outside",
"description":"Defines a shape, which may be non-rectangular, around which adjacent inline content should wrap.",
"url":"https://www.caniemail.com/features/css-shape-outside/",
"category":"css",
"tags":[],
"keywords":"shape",
"last_test_date":"2024-02-28",
"test_url":"https://www.caniemail.com/tests/css-shape-outside.html",
"test_results_url":"https://testi.at/proj/6vdjc5l6ungvfjgu94",
"stats":{"apple-mail":{"macos":{"15":"y"},"ios":{"15":"y"}},"gmail":{"desktop-webmail":{"2024-02":"n"},"ios":{"2024-02":"n"},"android":{"2024-02":"n"},"mobile-webmail":{"2024-02":"n"}},"orange":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-02":"n"},"macos":{"16.56":"y"},"outlook-com":{"2024-02":"n"},"ios":{"2024-02":"n"},"android":{"2024-02":"n"}},"samsung-email":{"android":{"2024-02":"y"}},"sfr":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"thunderbird":{"macos":{"2024-02":"y"}},"aol":{"desktop-webmail":{"2024-02":"n"},"ios":{"2024-02":"n"},"android":{"2024-02":"n"}},"yahoo":{"desktop-webmail":{"2024-02":"n"},"ios":{"2024-02":"n"},"android":{"2024-02":"n"}},"protonmail":{"desktop-webmail":{"2024-02":"u"},"ios":{"2024-02":"u"},"android":{"2024-02":"u"}},"hey":{"desktop-webmail":{"2024-02":"u"}},"mail-ru":{"desktop-webmail":{"2024-02":"y"}},"fastmail":{"desktop-webmail":{"2024-02":"u"}},"laposte":{"desktop-webmail":{"2024-02":"u"}}},
"notes":null,
"notes_by_num":null
},
{ {
"slug":"css-sytem-ui", "slug":"css-sytem-ui",
"title":"system-ui, ui-serif, ui-sans-serif, ui-rounded, ui-monospace", "title":"system-ui, ui-serif, ui-sans-serif, ui-rounded, ui-monospace",
@ -2334,7 +2414,7 @@
"last_test_date":"2022-08-31", "last_test_date":"2022-08-31",
"test_url":"https://www.caniemail.com/tests/css-text-align-last.html", "test_url":"https://www.caniemail.com/tests/css-text-align-last.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/LxplTmJT9Ilq9GUyn8Aq8MVK6EO427qmx1Ic4A7jc7bOJ/list", "test_results_url":"https://app.emailonacid.com/app/acidtest/LxplTmJT9Ilq9GUyn8Aq8MVK6EO427qmx1Ic4A7jc7bOJ/list",
"stats":{"apple-mail":{"macos":{"2022-10":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"n","16.0":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2021-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"n","16.80":"n"},"outlook-com":{"2022-08":"y","2024-01":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.51.1":"y"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"t-online-de":{"desktop-webmail":{"2022-08":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}}}, "stats":{"apple-mail":{"macos":{"2022-10":"y"},"ios":{"11":"n","12":"n","13":"n","14":"n","15":"n","16.0":"y"}},"gmail":{"desktop-webmail":{"2022-08":"y"},"ios":{"2022-08":"y"},"android":{"2022-08":"y"},"mobile-webmail":{"2021-08":"y"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"macos":{"2022-08":"n","16.80":"n"},"outlook-com":{"2022-08":"y","2024-01":"n"},"ios":{"2022-08":"n"},"android":{"2022-08":"n"}},"yahoo":{"desktop-webmail":{"2022-08":"n","2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2022-08":"n"}},"samsung-email":{"android":{"6.1.51.1":"y"}},"free-fr":{"desktop-webmail":{"2022-08":"y"}},"t-online-de":{"desktop-webmail":{"2022-08":"y"}},"mail-ru":{"desktop-webmail":{"2022-08":"y"}}},
"notes":null, "notes":null,
"notes_by_num":null "notes_by_num":null
}, },
@ -2595,6 +2675,22 @@
"notes_by_num":{"1":"Partial. Supports `under` but not `right` and `left`"} "notes_by_num":{"1":"Partial. Supports `under` but not `right` and `left`"}
}, },
{
"slug":"css-text-wrap",
"title":"text-wrap",
"description":"Controls how text inside an element is wrapped",
"url":"https://www.caniemail.com/features/css-text-wrap/",
"category":"css",
"tags":[],
"keywords":null,
"last_test_date":"2024-04-03",
"test_url":"https://www.caniemail.com/tests/css-text-wrap.html",
"test_results_url":"https://testi.at/proj/xle5u5a5i9eh9opi7a",
"stats":{"apple-mail":{"macos":{"2024-04":"y"},"ios":{"15":"y","14":"y"}},"gmail":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"},"mobile-webmail":{"2024-04":"n"}},"orange":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-04":"n"},"macos":{"2024-04":"n"},"outlook-com":{"2024-04":"n","2024-01":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"yahoo":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"aol":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"n"},"android":{"2024-04":"n"}},"samsung-email":{"android":{"2024-04":"y"}},"sfr":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"thunderbird":{"macos":{"2024-04":"u"}},"protonmail":{"desktop-webmail":{"2024-04":"u"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"hey":{"desktop-webmail":{"2024-04":"u"}},"mail-ru":{"desktop-webmail":{"2024-04":"y"}},"fastmail":{"desktop-webmail":{"2024-04":"u"}},"laposte":{"desktop-webmail":{"2024-04":"u"}},"gmx":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"web-de":{"desktop-webmail":{"2024-04":"n"},"ios":{"2024-04":"u"},"android":{"2024-04":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-04":"u"},"android":{"2024-04":"u"}}},
"notes":null,
"notes_by_num":null
},
{ {
"slug":"css-transform", "slug":"css-transform",
"title":"transform", "title":"transform",
@ -2611,6 +2707,22 @@
"notes_by_num":{"1":"Partial. `scale translate` is not supported."} "notes_by_num":{"1":"Partial. `scale translate` is not supported."}
}, },
{
"slug":"css-transition",
"title":"transition",
"description":"Creates a visual transition when changing CSS properties.",
"url":"https://www.caniemail.com/features/css-transition/",
"category":"css",
"tags":[],
"keywords":"animation",
"last_test_date":"2024-03-29",
"test_url":"https://www.caniemail.com/tests/css-transition.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/04RtAsXcEt68kJzhGogIDE8xcmwE7xuKdGPlz2b07ZfJY/list",
"stats":{"apple-mail":{"macos":{"16.0":"y"},"ios":{"17.4":"y"}},"gmail":{"desktop-webmail":{"2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"},"mobile-webmail":{"2024-03":"n"}},"orange":{"desktop-webmail":{"2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-03":"n"},"macos":{"16.80":"n"},"outlook-com":{"2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"6.1.90.16":"a #5"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"thunderbird":{"macos":{"115.9.0":"y"}},"aol":{"desktop-webmail":{"2024-03":"a #1 #2"},"ios":{"2024-03":"a #1 #2"},"android":{"2024-03":"a #1 #2"}},"yahoo":{"desktop-webmail":{"2024-03":"a #1 #2"},"ios":{"2024-03":"a #1 #2"},"android":{"2024-03":"a #1 #2"}},"protonmail":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"n"},"android":{"2024-03":"u"}},"hey":{"desktop-webmail":{"2024-03":"a #3"}},"mail-ru":{"desktop-webmail":{"2024-03":"y"}},"fastmail":{"desktop-webmail":{"2024-03":"y #4"}},"laposte":{"desktop-webmail":{"2024-03":"y"}},"free-fr":{"desktop-webmail":{"2024-03":"y"}},"t-online-de":{"desktop-webmail":{"2024-03":"u"}},"gmx":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"web-de":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-03":"u"},"android":{"2024-03":"u"}}},
"notes":"This page accounts for the shorthand `transition` property and the longhand properties `transition-delay`, `transition-duration`, `transition-property` and `transition-timing-function`.",
"notes_by_num":{"1":"Partial. Longhand properties are not supported.","2":"Buggy. The `all` keyword is not supported.","3":"Buggy. `transition-duration` is forced to `0` on a global reset style.","4":"Transition properties are supported but pseudo-classes like `:hover` are not.","5":"Partial. Not supported with Outlook accounts."}
},
{ {
"slug":"css-unit-calc", "slug":"css-unit-calc",
"title":"CSS calc() function", "title":"CSS calc() function",
@ -2973,10 +3085,10 @@
"keywords":null, "keywords":null,
"last_test_date":"2019-09-27", "last_test_date":"2019-09-27",
"test_url":"https://www.caniemail.com/tests/css-width-height.html", "test_url":"https://www.caniemail.com/tests/css-width-height.html",
"test_results_url":"https://app.emailonacid.com/app/acidtest/UhsQmS14DHKFfotKEcCTnWaoAiS24FJMiApZ1OtmHR7vs/list", "test_results_url":"https://app.emailonacid.com/app/acidtest/N3bgM8CXDd1TNWZzO65F0RkiJwugaAuNYr8mvcYt1C3Da/list",
"stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-09":"y","2021-03":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #1 #2","2010":"a #1 #2","2013":"a #1 #2","2016":"a #1 #2","2019":"a #1 #2"},"windows-mail":{"2019-09":"y"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-09":"y","2024-01":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"yahoo":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"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":"y"}},"laposte":{"desktop-webmail":{"2021-08":"a #3"}},"gmx":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"web-de":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"y"},"android":{"2021-12":"y"}}}, "stats":{"apple-mail":{"macos":{"12.4":"y"},"ios":{"12.4":"y"}},"gmail":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"},"mobile-webmail":{"2020-02":"y"}},"orange":{"desktop-webmail":{"2019-09":"y","2021-03":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"outlook":{"windows":{"2003":"y","2007":"a #1","2010":"a #1","2013":"a #1","2016":"a #1","2019":"a #1"},"windows-mail":{"2019-09":"y"},"macos":{"2011":"y","2016":"y","16.80":"y"},"outlook-com":{"2019-09":"y","2024-01":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"samsung-email":{"android":{"6.0":"y"}},"sfr":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"y"}},"yahoo":{"desktop-webmail":{"2019-09":"y"},"ios":{"2019-09":"y"},"android":{"2019-09":"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":"y"}},"laposte":{"desktop-webmail":{"2021-08":"a #2"}},"gmx":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"web-de":{"desktop-webmail":{"2021-12":"y"},"ios":{"2021-12":"y"},"android":{"2021-12":"y"}},"ionos-1and1":{"desktop-webmail":{"2021-12":"y"},"android":{"2021-12":"y"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Buggy. Percentage width on `<img>` elements are based on the physical file's width, not on the parent element's width.","2":"Partial. Not supported on `<body>`, `<span>`, `<div>` or `<p>` elements.","3":"Buggy. The webmail has a generic style that sets `table { width:inherit; }`."} "notes_by_num":{"1":"Partial. Not supported on `<body>`, `<span>`, `<div>`, `<p>` or `<img>` elements.","2":"Buggy. The webmail has a generic style that sets `table { width:inherit; }`."}
}, },
{ {
@ -2995,6 +3107,22 @@
"notes_by_num":{"1":"Supported. But Gmail adds `<wbr>` every 30 characters.","2":"Buggy. Supported but a `word-wrap:break-word` is applied, making it look like `break-all`.","3":"Partially supported. Only `word-break:break-all` works.","4":"Buggy. Every value is replaced by `break-word`."} "notes_by_num":{"1":"Supported. But Gmail adds `<wbr>` every 30 characters.","2":"Buggy. Supported but a `word-wrap:break-word` is applied, making it look like `break-all`.","3":"Partially supported. Only `word-break:break-all` works.","4":"Buggy. Every value is replaced by `break-word`."}
}, },
{
"slug":"css-word-spacing",
"title":"word-spacing",
"description":"Sets the length of space between words and between tags.",
"url":"https://www.caniemail.com/features/css-word-spacing/",
"category":"css",
"tags":[],
"keywords":"word",
"last_test_date":"2024-03-13",
"test_url":"https://www.caniemail.com/tests/css-word-spacing.html",
"test_results_url":"https://testi.at/proj/8r8pfrlvcxklca32ij",
"stats":{"apple-mail":{"macos":{"2024-03":"y"},"ios":{"2024-03":"y"}},"gmail":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"},"mobile-webmail":{"2024-03":"y"}},"orange":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"outlook":{"windows":{"2013":"n","2016":"n","2019":"n","2021":"n"},"windows-mail":{"2024-03":"n"},"macos":{"2024-03":"y"},"outlook-com":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"yahoo":{"desktop-webmail":{"2024-03":"a #1"},"ios":{"2024-03":"a #1"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2024-03":"a #1"},"ios":{"2024-03":"a #1"},"android":{"2024-03":"n"}},"samsung-email":{"android":{"2024-03":"y"}},"sfr":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"thunderbird":{"macos":{"2024-03":"y"}},"protonmail":{"desktop-webmail":{"2024-03":"u"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"hey":{"desktop-webmail":{"2024-03":"u"}},"mail-ru":{"desktop-webmail":{"2024-03":"y"}},"fastmail":{"desktop-webmail":{"2024-03":"u"}},"laposte":{"desktop-webmail":{"2024-03":"u"}},"gmx":{"desktop-webmail":{"2024-03":"a #1"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"web-de":{"desktop-webmail":{"2024-03":"a #1"},"ios":{"2024-03":"u"},"android":{"2024-03":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-03":"u"},"android":{"2024-03":"u"}}},
"notes":null,
"notes_by_num":{"1":"Does not support `rem` and `ch` values."}
},
{ {
"slug":"css-writing-mode", "slug":"css-writing-mode",
"title":"writing-mode", "title":"writing-mode",
@ -3054,7 +3182,7 @@
"last_test_date":"2024-01-03", "last_test_date":"2024-01-03",
"test_url":"https://www.caniemail.com/tests/html-acronym.html", "test_url":"https://www.caniemail.com/tests/html-acronym.html",
"test_results_url":"https://testi.at/proj/ayebhgpxu58yce2bhd", "test_results_url":"https://testi.at/proj/ayebhgpxu58yce2bhd",
"stats":{"apple-mail":{"macos":{"2024-01":"y"},"ios":{"2024-01":"y"}},"gmail":{"desktop-webmail":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"},"mobile-webmail":{"2024-01":"y"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2013":"n #1","2016":"n #1","2019":"n #1","2021":"n #1"},"windows-mail":{"2024-01":"n #1"},"macos":{"2024-01":"y"},"outlook-com":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"}},"samsung-email":{"android":{"2024-01":"y"}},"sfr":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"thunderbird":{"macos":{"2024-01":"y"}},"aol":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"yahoo":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"protonmail":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"hey":{"desktop-webmail":{"2024-01":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"free-fr":{"desktop-webmail":{"2024-01":"u"}},"t-online-de":{"desktop-webmail":{"2024-01":"y"}},"gmx":{"desktop-webmail":{"2024-01":"y"}},"web-de":{"desktop-webmail":{"2024-01":"y"}}}, "stats":{"apple-mail":{"macos":{"2024-01":"y"},"ios":{"2024-01":"y"}},"gmail":{"desktop-webmail":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"},"mobile-webmail":{"2024-01":"y"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2013":"n #1","2016":"n #1","2019":"n #1","2021":"n #1"},"windows-mail":{"2024-01":"n #1"},"macos":{"2024-01":"y"},"outlook-com":{"2024-01":"y"},"ios":{"2024-01":"y"},"android":{"2024-01":"y"}},"samsung-email":{"android":{"2024-01":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2024-01":"y"}},"aol":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"yahoo":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"protonmail":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"hey":{"desktop-webmail":{"2024-01":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"n"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"free-fr":{"desktop-webmail":{"2024-01":"u"}},"t-online-de":{"desktop-webmail":{"2024-01":"y"}},"gmx":{"desktop-webmail":{"2024-01":"y"}},"web-de":{"desktop-webmail":{"2024-01":"y"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Buggy. `title` attribute is removed but keeps `<acronym>` tag."} "notes_by_num":{"1":"Buggy. `title` attribute is removed but keeps `<acronym>` tag."}
}, },
@ -3358,7 +3486,7 @@
"last_test_date":"2023-09-11", "last_test_date":"2023-09-11",
"test_url":"https://www.caniemail.com/tests/html-dfn.html", "test_url":"https://www.caniemail.com/tests/html-dfn.html",
"test_results_url":"https://testi.at/proj/gy2dfo4j19d4176d08y", "test_results_url":"https://testi.at/proj/gy2dfo4j19d4176d08y",
"stats":{"apple-mail":{"macos":{"2023-09":"y"},"ios":{"2023-09":"y"}},"gmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"},"mobile-webmail":{"2023-09":"y"}},"orange":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"outlook":{"windows":{"2013":"a #1","2016":"a #1","2019":"a #1","2021":"a #1"},"windows-mail":{"2023-09":"a #1"},"macos":{"16.56":"y","16.80":"y"},"outlook-com":{"2023-09":"y","2024-01":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"samsung-email":{"android":{"2023-09":"y"}},"sfr":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"thunderbird":{"macos":{"2023-09":"y"}},"aol":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"yahoo":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"protonmail":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"hey":{"desktop-webmail":{"2023-09":"u"}},"mail-ru":{"desktop-webmail":{"2023-09":"n"}},"fastmail":{"desktop-webmail":{"2023-09":"u"}},"laposte":{"desktop-webmail":{"2023-09":"u"}},"free-fr":{"desktop-webmail":{"2023-09":"u"}},"t-online-de":{"desktop-webmail":{"2023-09":"n"}},"gmx":{"desktop-webmail":{"2023-09":"y"}},"web-de":{"desktop-webmail":{"2023-09":"y"}}}, "stats":{"apple-mail":{"macos":{"2023-09":"y"},"ios":{"2023-09":"y"}},"gmail":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"},"mobile-webmail":{"2023-09":"y"}},"orange":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"outlook":{"windows":{"2013":"a #1","2016":"a #1","2019":"a #1","2021":"a #1"},"windows-mail":{"2023-09":"a #1"},"macos":{"16.56":"y","16.80":"y"},"outlook-com":{"2023-09":"y","2024-01":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"samsung-email":{"android":{"2023-09":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"2023-09":"y"}},"aol":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"yahoo":{"desktop-webmail":{"2023-09":"y"},"ios":{"2023-09":"y"},"android":{"2023-09":"y"}},"protonmail":{"desktop-webmail":{"2023-09":"u"},"ios":{"2023-09":"u"},"android":{"2023-09":"u"}},"hey":{"desktop-webmail":{"2023-09":"u"}},"mail-ru":{"desktop-webmail":{"2023-09":"n"}},"fastmail":{"desktop-webmail":{"2023-09":"u"}},"laposte":{"desktop-webmail":{"2023-09":"u"}},"free-fr":{"desktop-webmail":{"2023-09":"u"}},"t-online-de":{"desktop-webmail":{"2023-09":"n"}},"gmx":{"desktop-webmail":{"2023-09":"y"}},"web-de":{"desktop-webmail":{"2023-09":"y"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Partial. The `title` attribute is removed."} "notes_by_num":{"1":"Partial. The `title` attribute is removed."}
}, },
@ -3486,7 +3614,7 @@
"last_test_date":"2024-01-26", "last_test_date":"2024-01-26",
"test_url":"https://www.caniemail.com/tests/html-hidden-attribute.html", "test_url":"https://www.caniemail.com/tests/html-hidden-attribute.html",
"test_results_url":"https://testi.at/proj/rlpli9r9trvs62rt7p", "test_results_url":"https://testi.at/proj/rlpli9r9trvs62rt7p",
"stats":{"apple-mail":{"macos":{"20":"y","21":"y","22":"y","23":"y"},"ios":{"14":"y","15":"y","16":"y","17":"y"}},"gmail":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"},"mobile-webmail":{"2024-01":"n"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-01":"n"},"macos":{"2016":"y","2024-01":"n"},"outlook-com":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"samsung-email":{"android":{"6.1":"y"}},"sfr":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"yahoo":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"protonmail":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"hey":{"desktop-webmail":{"2024-01":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"a #1"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"gmx":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"web-de":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-01":"u"},"android":{"2024-01":"u"}}}, "stats":{"apple-mail":{"macos":{"20":"y","21":"y","22":"y","23":"y"},"ios":{"14":"y","15":"y","16":"y","17":"y"}},"gmail":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"},"mobile-webmail":{"2024-01":"n"}},"orange":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"outlook":{"windows":{"2003":"n","2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2024-01":"n"},"macos":{"2016":"y","2024-01":"n"},"outlook-com":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"samsung-email":{"android":{"6.1":"y"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"y"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"60.3":"y"}},"aol":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"yahoo":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"n"},"android":{"2024-01":"n"}},"protonmail":{"desktop-webmail":{"2024-01":"u"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"hey":{"desktop-webmail":{"2024-01":"u"}},"mail-ru":{"desktop-webmail":{"2024-01":"a #1"}},"fastmail":{"desktop-webmail":{"2024-01":"u"}},"laposte":{"desktop-webmail":{"2024-01":"u"}},"gmx":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"web-de":{"desktop-webmail":{"2024-01":"n"},"ios":{"2024-01":"u"},"android":{"2024-01":"u"}},"ionos-1and1":{"desktop-webmail":{"2024-01":"u"},"android":{"2024-01":"u"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Does not support the unquoted attribute value syntax `<div hidden></div>`"} "notes_by_num":{"1":"Does not support the unquoted attribute value syntax `<div hidden></div>`"}
}, },
@ -3742,7 +3870,7 @@
"last_test_date":"2023-09-18", "last_test_date":"2023-09-18",
"test_url":"https://www.caniemail.com/tests/html-meta-color-scheme.html", "test_url":"https://www.caniemail.com/tests/html-meta-color-scheme.html",
"test_results_url":"", "test_results_url":"",
"stats":{"apple-mail":{"macos":{"16":"y"},"ios":{"12.4":"n"}},"gmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2019-09":"n"},"android":{"2023-09":"n"},"mobile-webmail":{"2023-09":"n"}},"orange":{"desktop-webmail":{"2021-03":"u"},"ios":{"2020-01":"u"},"android":{"2020-01":"u"}},"outlook":{"windows":{"2003":"u","2007":"u","2010":"u","2013":"u","2016":"n","2019":"n"},"windows-mail":{"2023-09":"n"},"macos":{"2023":"n","16.80":"n"},"outlook-com":{"2023-09":"n","2024-01":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"samsung-email":{"android":{"9.1":"n"}},"sfr":{"desktop-webmail":{"2020-01":"u"},"ios":{"2020-01":"u"},"android":{"2020-01":"u"}},"thunderbird":{"macos":{"102.11":"y"}},"yahoo":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"aol":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"protonmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"hey":{"desktop-webmail":{"2023-09":"n"}},"mail-ru":{"desktop-webmail":{"2023-09":"n"}},"fastmail":{"desktop-webmail":{"2023-09":"n"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2023-09":"n"},"ios":{"2022-11":"u"},"android":{"2022-11":"u"}},"web-de":{"desktop-webmail":{"2023-09":"n"},"ios":{"2022-11":"u"},"android":{"2022-11":"u"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"u"},"android":{"2022-11":"u"}}}, "stats":{"apple-mail":{"macos":{"16":"y"},"ios":{"12.4":"n"}},"gmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2019-09":"n"},"android":{"2023-09":"n"},"mobile-webmail":{"2023-09":"n"}},"orange":{"desktop-webmail":{"2021-03":"u"},"ios":{"2020-01":"u"},"android":{"2020-01":"u"}},"outlook":{"windows":{"2003":"u","2007":"u","2010":"u","2013":"u","2016":"n","2019":"n"},"windows-mail":{"2023-09":"n"},"macos":{"2023":"n","16.80":"n"},"outlook-com":{"2023-09":"n","2024-01":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"samsung-email":{"android":{"9.1":"n"}},"sfr":{"desktop-webmail":{"2024-03":"n"},"ios":{"2024-03":"n"},"android":{"2024-03":"n"}},"thunderbird":{"macos":{"102.11":"y"}},"yahoo":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"aol":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"protonmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"n"}},"hey":{"desktop-webmail":{"2023-09":"n"}},"mail-ru":{"desktop-webmail":{"2023-09":"n"}},"fastmail":{"desktop-webmail":{"2023-09":"n"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2023-09":"n"},"ios":{"2022-11":"u"},"android":{"2022-11":"u"}},"web-de":{"desktop-webmail":{"2023-09":"n"},"ios":{"2022-11":"u"},"android":{"2022-11":"u"}},"ionos-1and1":{"desktop-webmail":{"2022-11":"u"},"android":{"2022-11":"u"}}},
"notes":"", "notes":"",
"notes_by_num":null "notes_by_num":null
}, },
@ -3822,7 +3950,7 @@
"last_test_date":"2023-09-25", "last_test_date":"2023-09-25",
"test_url":"https://www.caniemail.com/tests/html-popover.html", "test_url":"https://www.caniemail.com/tests/html-popover.html",
"test_results_url":"", "test_results_url":"",
"stats":{"apple-mail":{"macos":{"16":"y"},"ios":{"16":"y"}},"gmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"u"},"mobile-webmail":{"2023-09":"u"}},"orange":{"desktop-webmail":{"2019-08":"u","2021-03":"u"},"ios":{"2019-08":"u"},"android":{"2019-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-09":"n"},"macos":{"2023-09":"n","16.80":"n"},"outlook-com":{"2023-09":"n","2024-01":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"u"}},"yahoo":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2019-02":"u"}},"aol":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"u"}},"samsung-email":{"android":{"5.0.10.2":"u","6.1.90.16":"a #1"}},"sfr":{"desktop-webmail":{"2019-08":"u"},"ios":{"2019-08":"u"},"android":{"2019-08":"u"}},"thunderbird":{"macos":{"115.2":"n"}},"protonmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"u"}},"hey":{"desktop-webmail":{"2023-09":"n"}},"mail-ru":{"desktop-webmail":{"2024-01":"a"}},"fastmail":{"desktop-webmail":{"2023-09":"n"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2022-06":"u"},"ios":{"2022-06":"u"},"android":{"2022-06":"u"}},"web-de":{"desktop-webmail":{"2022-06":"u"},"ios":{"2022-06":"u"},"android":{"2022-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"u"},"android":{"2022-06":"u"}}}, "stats":{"apple-mail":{"macos":{"16":"y"},"ios":{"16":"y"}},"gmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2024-03":"n"},"mobile-webmail":{"2024-03":"n"}},"orange":{"desktop-webmail":{"2019-08":"u","2021-03":"u"},"ios":{"2019-08":"u"},"android":{"2019-08":"u"}},"outlook":{"windows":{"2007":"n","2010":"n","2013":"n","2016":"n","2019":"n"},"windows-mail":{"2023-09":"n"},"macos":{"2023-09":"n","16.80":"n"},"outlook-com":{"2023-09":"n","2024-01":"n"},"ios":{"2023-09":"n"},"android":{"2024-03":"n"}},"yahoo":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2024-03":"n"}},"aol":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"u"}},"samsung-email":{"android":{"5.0.10.2":"u","6.1.90.16":"a #1"}},"sfr":{"desktop-webmail":{"2024-03":"y"},"ios":{"2024-03":"n"},"android":{"2024-03":"y"}},"thunderbird":{"macos":{"115.2":"n"}},"protonmail":{"desktop-webmail":{"2023-09":"n"},"ios":{"2023-09":"n"},"android":{"2023-09":"u"}},"hey":{"desktop-webmail":{"2023-09":"n"}},"mail-ru":{"desktop-webmail":{"2024-01":"a"}},"fastmail":{"desktop-webmail":{"2023-09":"n"}},"laposte":{"desktop-webmail":{"2021-08":"u"}},"gmx":{"desktop-webmail":{"2022-06":"u"},"ios":{"2022-06":"u"},"android":{"2022-06":"u"}},"web-de":{"desktop-webmail":{"2022-06":"u"},"ios":{"2022-06":"u"},"android":{"2022-06":"u"}},"ionos-1and1":{"desktop-webmail":{"2022-06":"u"},"android":{"2022-06":"u"}}},
"notes":null, "notes":null,
"notes_by_num":{"1":"Partial. Not supported with Hotmail/Outlook accounts.","2":"Partial. The `popovertarget` attribute is supported, but the `popover` attribute and the `dialog` element are not."} "notes_by_num":{"1":"Partial. Not supported with Hotmail/Outlook accounts.","2":"Partial. The `popovertarget` attribute is supported, but the `popover` attribute and the `dialog` element are not."}
}, },

View File

@ -2,7 +2,6 @@
package stats package stats
import ( import (
"os"
"runtime" "runtime"
"sync" "sync"
"time" "time"
@ -21,10 +20,10 @@ var (
mu sync.RWMutex mu sync.RWMutex
smtpAccepted int smtpAccepted float64
smtpAcceptedSize int smtpAcceptedSize float64
smtpRejected int smtpRejected float64
smtpIgnored int smtpIgnored float64
) )
// AppInformation struct // AppInformation struct
@ -37,29 +36,29 @@ type AppInformation struct {
// Database path // Database path
Database string Database string
// Database size in bytes // Database size in bytes
DatabaseSize int64 DatabaseSize float64
// Total number of messages in the database // Total number of messages in the database
Messages int Messages float64
// Total number of messages in the database // Total number of messages in the database
Unread int Unread float64
// Tags and message totals per tag // Tags and message totals per tag
Tags map[string]int64 Tags map[string]int64
// Runtime statistics // Runtime statistics
RuntimeStats struct { RuntimeStats struct {
// Mailpit server uptime in seconds // Mailpit server uptime in seconds
Uptime int Uptime float64
// Current memory usage in bytes // Current memory usage in bytes
Memory uint64 Memory uint64
// Database runtime messages deleted // Database runtime messages deleted
MessagesDeleted int MessagesDeleted float64
// Accepted runtime SMTP messages // Accepted runtime SMTP messages
SMTPAccepted int SMTPAccepted float64
// Total runtime accepted messages size in bytes // Total runtime accepted messages size in bytes
SMTPAcceptedSize int SMTPAcceptedSize float64
// Rejected runtime SMTP messages // Rejected runtime SMTP messages
SMTPRejected int SMTPRejected float64
// Ignored runtime SMTP messages (when using --ignore-duplicate-ids) // Ignored runtime SMTP messages (when using --ignore-duplicate-ids)
SMTPIgnored int SMTPIgnored float64
} }
} }
@ -72,8 +71,7 @@ func Load() AppInformation {
runtime.ReadMemStats(&m) runtime.ReadMemStats(&m)
info.RuntimeStats.Memory = m.Sys - m.HeapReleased info.RuntimeStats.Memory = m.Sys - m.HeapReleased
info.RuntimeStats.Uptime = time.Since(startedAt).Seconds()
info.RuntimeStats.Uptime = int(time.Since(startedAt).Seconds())
info.RuntimeStats.MessagesDeleted = storage.StatsDeleted info.RuntimeStats.MessagesDeleted = storage.StatsDeleted
info.RuntimeStats.SMTPAccepted = smtpAccepted info.RuntimeStats.SMTPAccepted = smtpAccepted
info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize info.RuntimeStats.SMTPAcceptedSize = smtpAcceptedSize
@ -96,16 +94,10 @@ func Load() AppInformation {
} }
} }
info.Database = config.DataFile info.Database = config.Database
info.DatabaseSize = storage.DbSize()
db, err := os.Stat(info.Database)
if err == nil {
info.DatabaseSize = db.Size()
}
info.Messages = storage.CountTotal() info.Messages = storage.CountTotal()
info.Unread = storage.CountUnread() info.Unread = storage.CountUnread()
info.Tags = storage.GetAllTagsCount() info.Tags = storage.GetAllTagsCount()
return info return info
@ -120,7 +112,7 @@ func Track() {
func LogSMTPAccepted(size int) { func LogSMTPAccepted(size int) {
mu.Lock() mu.Lock()
smtpAccepted = smtpAccepted + 1 smtpAccepted = smtpAccepted + 1
smtpAcceptedSize = smtpAcceptedSize + size smtpAcceptedSize = smtpAcceptedSize + float64(size)
mu.Unlock() mu.Unlock()
} }

View File

@ -27,7 +27,7 @@ func dbCron() {
if deletedSize > 0 { if deletedSize > 0 {
total := totalMessagesSize() total := totalMessagesSize()
var deletedPercent int64 var deletedPercent float64
if total == 0 { if total == 0 {
deletedPercent = 100 deletedPercent = 100
} else { } else {
@ -35,7 +35,7 @@ func dbCron() {
} }
// only vacuum the DB if at least 1% of mail storage size has been deleted // only vacuum the DB if at least 1% of mail storage size has been deleted
if deletedPercent >= 1 { if deletedPercent >= 1 {
logger.Log().Debugf("[db] deleted messages is %d%% of total size, reclaim space", deletedPercent) logger.Log().Debugf("[db] deleted messages is %f%% of total size, reclaim space", deletedPercent)
vacuumDb() vacuumDb()
} }
} }
@ -55,15 +55,15 @@ func pruneMessages() {
start := time.Now() start := time.Now()
q := sqlf.Select("ID, Size"). q := sqlf.Select("ID, Size").
From("mailbox"). From(tenant("mailbox")).
OrderBy("Created DESC"). OrderBy("Created DESC").
Limit(5000). Limit(5000).
Offset(config.MaxMessages) Offset(config.MaxMessages)
ids := []string{} ids := []string{}
var prunedSize int64 var prunedSize int64
var size int var size float64
if err := q.Query(context.TODO(), db, func(row *sql.Rows) { if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string var id string
if err := row.Scan(&id, &size); err != nil { if err := row.Scan(&id, &size); err != nil {
@ -93,19 +93,19 @@ func pruneMessages() {
args[i] = id args[i] = id
} }
_, err = tx.Query(`DELETE FROM mailbox WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec _, err = tx.Exec(`DELETE FROM `+tenant("mailbox_data")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil { if err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
return return
} }
_, err = tx.Query(`DELETE FROM mailbox_data WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec _, err = tx.Exec(`DELETE FROM `+tenant("message_tags")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil { if err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
return return
} }
_, err = tx.Query(`DELETE FROM message_tags WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec _, err = tx.Exec(`DELETE FROM `+tenant("mailbox")+` WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil { if err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
return return
@ -137,6 +137,11 @@ func pruneMessages() {
// Vacuum the database to reclaim space from deleted messages // Vacuum the database to reclaim space from deleted messages
func vacuumDb() { func vacuumDb() {
if sqlDriver == "rqlite" {
// let rqlite handle vacuuming
return
}
start := time.Now() start := time.Now()
// set WAL file checkpoint // set WAL file checkpoint
@ -147,7 +152,7 @@ func vacuumDb() {
// vacuum database // vacuum database
if _, err := db.Exec("VACUUM"); err != nil { if _, err := db.Exec("VACUUM"); err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] VACUUM: %s", err.Error())
return return
} }
@ -162,5 +167,5 @@ func vacuumDb() {
} }
elapsed := time.Since(start) elapsed := time.Since(start)
logger.Log().Debugf("[db] vacuumed database in %s", elapsed) logger.Log().Debugf("[db] vacuum completed in %s", elapsed)
} }

View File

@ -9,6 +9,7 @@ import (
"os/signal" "os/signal"
"path" "path"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
@ -17,14 +18,18 @@ import (
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf" "github.com/leporo/sqlf"
// sqlite (native) - https://gitlab.com/cznic/sqlite // sqlite - https://gitlab.com/cznic/sqlite
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
// rqlite - https://github.com/rqlite/gorqlite | https://rqlite.io/
_ "github.com/rqlite/gorqlite/stdlib"
) )
var ( var (
db *sql.DB db *sql.DB
dbFile string dbFile string
dbIsTemp bool dbIsTemp bool
sqlDriver string
dbLastAction time.Time dbLastAction time.Time
// zstd compression encoder & decoder // zstd compression encoder & decoder
@ -34,43 +39,61 @@ var (
// InitDB will initialise the database // InitDB will initialise the database
func InitDB() error { func InitDB() error {
p := config.DataFile p := config.Database
var dsn string
if p == "" { if p == "" {
// when no path is provided then we create a temporary file // when no path is provided then we create a temporary file
// which will get deleted on Close(), SIGINT or SIGTERM // which will get deleted on Close(), SIGINT or SIGTERM
p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano()) p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano())
dbIsTemp = true dbIsTemp = true
sqlDriver = "sqlite"
dsn = p
logger.Log().Debugf("[db] using temporary database: %s", p) logger.Log().Debugf("[db] using temporary database: %s", p)
} else if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
sqlDriver = "rqlite"
dsn = p
logger.Log().Debugf("[db] opening rqlite database %s", p)
} else { } else {
p = filepath.Clean(p) p = filepath.Clean(p)
sqlDriver = "sqlite"
dsn = fmt.Sprintf("file:%s?cache=shared", p)
logger.Log().Debugf("[db] opening database %s", p)
} }
config.DataFile = p config.Database = p
logger.Log().Debugf("[db] opening database %s", p)
var err error var err error
dsn := fmt.Sprintf("file:%s?cache=shared", p) db, err = sql.Open(sqlDriver, dsn)
db, err = sql.Open("sqlite", dsn)
if err != nil { if err != nil {
return err return err
} }
for i := 1; i < 6; i++ {
if err := Ping(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
logger.Log().Infof("[db] reconnecting in 5 seconds (%d/5)", i)
time.Sleep(5 * time.Second)
} else {
continue
}
}
// prevent "database locked" errors // prevent "database locked" errors
// @see https://github.com/mattn/go-sqlite3#faq // @see https://github.com/mattn/go-sqlite3#faq
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
// SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) if sqlDriver == "sqlite" {
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;") // SQLite performance tuning (https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)
if err != nil { _, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = normal;")
return err if err != nil {
return err
}
} }
// create tables if necessary & apply migrations // create tables if necessary & apply migrations
if err := dbApplyMigrations(); err != nil { if err := dbApplySchemas(); err != nil {
return err return err
} }
@ -99,6 +122,11 @@ func InitDB() error {
return nil return nil
} }
// Tenant applies an optional prefix to the table name
func tenant(table string) string {
return fmt.Sprintf("%s%s", config.TenantID, table)
}
// Close will close the database, and delete if a temporary table // Close will close the database, and delete if a temporary table
func Close() { func Close() {
if db != nil { if db != nil {
@ -138,10 +166,10 @@ func StatsGet() MailboxStats {
} }
// CountTotal returns the number of emails in the database // CountTotal returns the number of emails in the database
func CountTotal() int { func CountTotal() float64 {
var total int var total float64
_ = sqlf.From("mailbox"). _ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total). Select("COUNT(*)").To(&total).
QueryRowAndClose(context.TODO(), db) QueryRowAndClose(context.TODO(), db)
@ -149,10 +177,10 @@ func CountTotal() int {
} }
// CountUnread returns the number of emails in the database that are unread. // CountUnread returns the number of emails in the database that are unread.
func CountUnread() int { func CountUnread() float64 {
var total int var total float64
_ = sqlf.From("mailbox"). _ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total). Select("COUNT(*)").To(&total).
Where("Read = ?", 0). Where("Read = ?", 0).
QueryRowAndClose(context.TODO(), db) QueryRowAndClose(context.TODO(), db)
@ -161,10 +189,10 @@ func CountUnread() int {
} }
// CountRead returns the number of emails in the database that are read. // CountRead returns the number of emails in the database that are read.
func CountRead() int { func CountRead() float64 {
var total int var total float64
_ = sqlf.From("mailbox"). _ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total). Select("COUNT(*)").To(&total).
Where("Read = ?", 1). Where("Read = ?", 1).
QueryRowAndClose(context.TODO(), db) QueryRowAndClose(context.TODO(), db)
@ -172,11 +200,25 @@ func CountRead() int {
return total return total
} }
// DbSize returns the size of the SQLite database.
func DbSize() float64 {
var total sql.NullFloat64
err := db.QueryRow("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()").Scan(&total)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return total.Float64
}
return total.Float64
}
// IsUnread returns whether a message is unread or not. // IsUnread returns whether a message is unread or not.
func IsUnread(id string) bool { func IsUnread(id string) bool {
var unread int var unread int
_ = sqlf.From("mailbox"). _ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&unread). Select("COUNT(*)").To(&unread).
Where("Read = ?", 0). Where("Read = ?", 0).
Where("ID = ?", id). Where("ID = ?", id).
@ -189,7 +231,7 @@ func IsUnread(id string) bool {
func MessageIDExists(id string) bool { func MessageIDExists(id string) bool {
var total int var total int
_ = sqlf.From("mailbox"). _ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total). Select("COUNT(*)").To(&total).
Where("MessageID = ?", id). Where("MessageID = ?", id).
QueryRowAndClose(context.TODO(), db) QueryRowAndClose(context.TODO(), db)

View File

@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -88,21 +90,27 @@ func Store(body *[]byte) (string, error) {
defer tx.Rollback() defer tx.Rollback()
subject := env.GetHeader("Subject") subject := env.GetHeader("Subject")
size := len(*body) size := float64(len(*body))
inline := len(env.Inlines) inline := len(env.Inlines)
attachments := len(env.Attachments) attachments := len(env.Attachments)
snippet := tools.CreateSnippet(env.Text, env.HTML) snippet := tools.CreateSnippet(env.Text, env.HTML)
sql := fmt.Sprintf(`INSERT INTO %s
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet)
VALUES(?,?,?,?,?,?,?,?,?,0,?)`,
tenant("mailbox"),
) // #nosec
// insert mail summary data // insert mail summary data
_, err = tx.Exec("INSERT INTO mailbox(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Snippet) values(?,?,?,?,?,?,?,?,?,0,?)", _, err = tx.Exec(sql, created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
created.UnixMilli(), id, messageID, subject, string(summaryJSON), size, inline, attachments, searchText, snippet)
if err != nil { if err != nil {
return "", err return "", err
} }
// insert compressed raw message // insert compressed raw message
compressed := dbEncoder.EncodeAll(*body, make([]byte, 0, size)) encoded := dbEncoder.EncodeAll(*body, make([]byte, 0, int(size)))
_, err = tx.Exec("INSERT INTO mailbox_data(ID, Email) values(?,?)", id, string(compressed)) hexStr := hex.EncodeToString(encoded)
_, err = tx.Exec(fmt.Sprintf(`INSERT INTO %s (ID, Email) VALUES(?, x'%s')`, tenant("mailbox_data"), hexStr), id) // #nosec
if err != nil { if err != nil {
return "", err return "", err
} }
@ -148,19 +156,19 @@ func List(start, limit int) ([]MessageSummary, error) {
results := []MessageSummary{} results := []MessageSummary{}
tsStart := time.Now() tsStart := time.Now()
q := sqlf.From("mailbox m"). q := sqlf.From(tenant("mailbox") + " m").
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`). Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, m.Snippet`).
OrderBy("m.Created DESC"). OrderBy("m.Created DESC").
Limit(limit). Limit(limit).
Offset(start) Offset(start)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created int64 var created float64
var id string var id string
var messageID string var messageID string
var subject string var subject string
var metadata string var metadata string
var size int var size float64
var attachments int var attachments int
var read int var read int
var snippet string var snippet string
@ -176,7 +184,7 @@ func List(start, limit int) ([]MessageSummary, error) {
return return
} }
em.Created = time.UnixMilli(created) em.Created = time.UnixMilli(int64(created))
em.ID = id em.ID = id
em.MessageID = messageID em.MessageID = messageID
em.Subject = subject em.Subject = subject
@ -241,12 +249,12 @@ func GetMessage(id string) (*Message, error) {
date, err := env.Date() date, err := env.Date()
if err != nil { if err != nil {
// return received datetime when message does not contain a date header // return received datetime when message does not contain a date header
q := sqlf.From("mailbox"). q := sqlf.From(tenant("mailbox")).
Select(`Created`). Select(`Created`).
Where(`ID = ?`, id) Where(`ID = ?`, id)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created int64 var created float64
if err := row.Scan(&created); err != nil { if err := row.Scan(&created); err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
@ -255,7 +263,7 @@ func GetMessage(id string) (*Message, error) {
logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id) logger.Log().Debugf("[db] %s does not contain a date header, using received datetime", id)
date = time.UnixMilli(created) date = time.UnixMilli(int64(created))
}); err != nil { }); err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
} }
@ -273,7 +281,7 @@ func GetMessage(id string) (*Message, error) {
ReturnPath: returnPath, ReturnPath: returnPath,
Subject: env.GetHeader("Subject"), Subject: env.GetHeader("Subject"),
Tags: getMessageTags(id), Tags: getMessageTags(id),
Size: len(raw), Size: float64(len(raw)),
Text: env.Text, Text: env.Text,
} }
@ -327,11 +335,10 @@ func GetMessage(id string) (*Message, error) {
func GetMessageRaw(id string) ([]byte, error) { func GetMessageRaw(id string) ([]byte, error) {
var i string var i string
var msg string var msg string
q := sqlf.From("mailbox_data"). q := sqlf.From(tenant("mailbox_data")).
Select(`ID`).To(&i). Select(`ID`).To(&i).
Select(`Email`).To(&msg). Select(`Email`).To(&msg).
Where(`ID = ?`, id) Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db) err := q.QueryRowAndClose(context.Background(), db)
if err != nil { if err != nil {
return nil, err return nil, err
@ -341,7 +348,17 @@ func GetMessageRaw(id string) ([]byte, error) {
return nil, errors.New("message not found") return nil, errors.New("message not found")
} }
raw, err := dbDecoder.DecodeAll([]byte(msg), nil) var data []byte
if sqlDriver == "rqlite" {
data, err = base64.StdEncoding.DecodeString(msg)
if err != nil {
return nil, fmt.Errorf("error decoding base64 message: %w", err)
}
} else {
data = []byte(msg)
}
raw, err := dbDecoder.DecodeAll(data, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error()) return nil, fmt.Errorf("error decompressing message: %s", err.Error())
} }
@ -398,7 +415,7 @@ func LatestID(r *http.Request) (string, error) {
search := strings.TrimSpace(r.URL.Query().Get("query")) search := strings.TrimSpace(r.URL.Query().Get("query"))
if search != "" { if search != "" {
messages, _, err = Search(search, 0, 1) messages, _, err = Search(search, r.URL.Query().Get("tz"), 0, 1)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -421,7 +438,7 @@ func MarkRead(id string) error {
return nil return nil
} }
_, err := sqlf.Update("mailbox"). _, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1). Set("Read", 1).
Where("ID = ?", id). Where("ID = ?", id).
ExecAndClose(context.Background(), db) ExecAndClose(context.Background(), db)
@ -442,7 +459,7 @@ func MarkAllRead() error {
total = CountUnread() total = CountUnread()
) )
_, err := sqlf.Update("mailbox"). _, err := sqlf.Update(tenant("mailbox")).
Set("Read", 1). Set("Read", 1).
Where("Read = ?", 0). Where("Read = ?", 0).
ExecAndClose(context.Background(), db) ExecAndClose(context.Background(), db)
@ -451,7 +468,7 @@ func MarkAllRead() error {
} }
elapsed := time.Since(start) elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed) logger.Log().Debugf("[db] marked %v messages as read in %s", total, elapsed)
BroadcastMailboxStats() BroadcastMailboxStats()
@ -467,7 +484,7 @@ func MarkAllUnread() error {
total = CountRead() total = CountRead()
) )
_, err := sqlf.Update("mailbox"). _, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0). Set("Read", 0).
Where("Read = ?", 1). Where("Read = ?", 1).
ExecAndClose(context.Background(), db) ExecAndClose(context.Background(), db)
@ -476,7 +493,7 @@ func MarkAllUnread() error {
} }
elapsed := time.Since(start) elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as unread in %s", total, elapsed) logger.Log().Debugf("[db] marked %v messages as unread in %s", total, elapsed)
BroadcastMailboxStats() BroadcastMailboxStats()
@ -491,7 +508,7 @@ func MarkUnread(id string) error {
return nil return nil
} }
_, err := sqlf.Update("mailbox"). _, err := sqlf.Update(tenant("mailbox")).
Set("Read", 0). Set("Read", 0).
Where("ID = ?", id). Where("ID = ?", id).
ExecAndClose(context.Background(), db) ExecAndClose(context.Background(), db)
@ -507,52 +524,89 @@ func MarkUnread(id string) error {
return err return err
} }
// DeleteOneMessage will delete a single message from a mailbox // DeleteMessages deletes one or more messages in bulk
func DeleteOneMessage(id string) error { func DeleteMessages(ids []string) error {
m, err := GetMessageRaw(id) if len(ids) == 0 {
return nil
}
start := time.Now()
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
sql := fmt.Sprintf(`SELECT ID, Size FROM %s WHERE ID IN (?%s)`, tenant("mailbox"), strings.Repeat(",?", len(args)-1)) // #nosec
rows, err := db.Query(sql, args...)
if err != nil { if err != nil {
return err return err
} }
defer rows.Close()
toDelete := []string{}
var totalSize float64
for rows.Next() {
var id string
var size float64
if err := rows.Scan(&id, &size); err != nil {
return err
}
toDelete = append(toDelete, id)
totalSize = totalSize + size
}
if err = rows.Err(); err != nil {
return err
}
if len(toDelete) == 0 {
return nil // nothing to delete
}
size := len(m)
// begin a transaction to ensure both the message
// and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil) tx, err := db.BeginTx(context.Background(), nil)
if err != nil { if err != nil {
return err return err
} }
// roll back if it fails args = make([]interface{}, len(toDelete))
defer tx.Rollback() for i, id := range toDelete {
args[i] = id
_, err = tx.Exec("DELETE FROM mailbox WHERE ID = ?", id)
if err != nil {
return err
} }
_, err = tx.Exec("DELETE FROM mailbox_data WHERE ID = ?", id) tables := []string{"mailbox", "mailbox_data", "message_tags"}
if err != nil {
return err for _, t := range tables {
sql = fmt.Sprintf(`DELETE FROM %s WHERE ID IN (?%s)`, tenant(t), strings.Repeat(",?", len(ids)-1))
_, err = tx.Exec(sql, args...) // #nosec
if err != nil {
return err
}
} }
err = tx.Commit() err = tx.Commit()
if err == nil {
logger.Log().Debugf("[db] deleted message %s", id)
}
if err := DeleteAllMessageTags(id); err != nil {
return err
}
dbLastAction = time.Now() dbLastAction = time.Now()
addDeletedSize(int64(size)) addDeletedSize(int64(totalSize))
logMessagesDeleted(1) logMessagesDeleted(len(toDelete))
_ = pruneUnusedTags()
elapsed := time.Since(start)
messages := "messages"
if len(toDelete) == 1 {
messages = "message"
}
logger.Log().Debugf("[db] deleted %d %s in %s", len(toDelete), messages, elapsed)
BroadcastMailboxStats() BroadcastMailboxStats()
return err return nil
} }
// DeleteAllMessages will delete all messages from a mailbox // DeleteAllMessages will delete all messages from a mailbox
@ -562,7 +616,7 @@ func DeleteAllMessages() error {
total int total int
) )
_ = sqlf.From("mailbox"). _ = sqlf.From(tenant("mailbox")).
Select("COUNT(*)").To(&total). Select("COUNT(*)").To(&total).
QueryRowAndClose(context.TODO(), db) QueryRowAndClose(context.TODO(), db)
@ -576,24 +630,14 @@ func DeleteAllMessages() error {
// roll back if it fails // roll back if it fails
defer tx.Rollback() defer tx.Rollback()
_, err = tx.Exec("DELETE FROM mailbox") tables := []string{"mailbox", "mailbox_data", "tags", "message_tags"}
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM mailbox_data") for _, t := range tables {
if err != nil { sql := fmt.Sprintf(`DELETE FROM %s`, tenant(t)) // #nosec
return err _, err := tx.Exec(sql)
} if err != nil {
return err
_, err = tx.Exec("DELETE FROM tags") }
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM message_tags")
if err != nil {
return err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {

View File

@ -13,8 +13,6 @@ func TestTextEmailInserts(t *testing.T) {
start := time.Now() start := time.Now()
assertEqualStats(t, 0, 0)
for i := 0; i < testRuns; i++ { for i := 0; i < testRuns; i++ {
if _, err := Store(&testTextEmail); err != nil { if _, err := Store(&testTextEmail); err != nil {
t.Log("error ", err) t.Log("error ", err)
@ -22,19 +20,17 @@ func TestTextEmailInserts(t *testing.T) {
} }
} }
assertEqual(t, CountTotal(), testRuns, "Incorrect number of text emails stored") assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of text emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start)) t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
assertEqualStats(t, testRuns, testRuns)
delStart := time.Now() delStart := time.Now()
if err := DeleteAllMessages(); err != nil { if err := DeleteAllMessages(); err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
} }
assertEqual(t, CountTotal(), 0, "incorrect number of text emails deleted") assertEqual(t, CountTotal(), float64(0), "incorrect number of text emails deleted")
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart)) t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
@ -56,19 +52,17 @@ func TestMimeEmailInserts(t *testing.T) {
} }
} }
assertEqual(t, CountTotal(), testRuns, "Incorrect number of mime emails stored") assertEqual(t, CountTotal(), float64(testRuns), "Incorrect number of mime emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start)) t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
assertEqualStats(t, testRuns, testRuns)
delStart := time.Now() delStart := time.Now()
if err := DeleteAllMessages(); err != nil { if err := DeleteAllMessages(); err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
} }
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted") assertEqual(t, CountTotal(), float64(0), "incorrect number of mime emails deleted")
t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart)) t.Logf("Deleted %d mime emails in %s", testRuns, time.Since(delStart))
} }
@ -107,14 +101,14 @@ func TestRetrieveMimeEmail(t *testing.T) {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
} }
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match") assertEqual(t, float64(len(attachmentData.Content)), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID) inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
} }
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match") assertEqual(t, float64(len(inlineData.Content)), msg.Inline[0].Size, "inline attachment size does not match")
} }
func TestMessageSummary(t *testing.T) { func TestMessageSummary(t *testing.T) {

View File

@ -1,189 +0,0 @@
package storage
import (
"context"
"database/sql"
"encoding/json"
"github.com/GuiaBolso/darwin"
"github.com/axllent/mailpit/internal/logger"
"github.com/leporo/sqlf"
)
var (
dbMigrations = []darwin.Migration{
{
Version: 1.0,
Description: "Creating tables",
Script: `CREATE TABLE IF NOT EXISTS mailbox (
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
Data BLOB,
Search TEXT,
Read INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sort ON mailbox (Sort);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE TABLE IF NOT EXISTS mailbox_data (
ID TEXT KEY NOT NULL,
Email BLOB
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
},
{
Version: 1.1,
Description: "Create tags column",
Script: `ALTER TABLE mailbox ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
{
Version: 1.2,
Description: "Creating new mailbox format",
Script: `CREATE TABLE IF NOT EXISTS mailboxtmp (
Created INTEGER NOT NULL,
ID TEXT NOT NULL,
MessageID TEXT NOT NULL,
Subject TEXT NOT NULL,
Metadata TEXT,
Size INTEGER NOT NULL,
Inline INTEGER NOT NULL,
Attachments INTEGER NOT NULL,
Read INTEGER,
Tags TEXT,
SearchText TEXT
);
INSERT INTO mailboxtmp
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
SELECT
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
Search, Read, Tags
FROM mailbox;
DROP TABLE IF EXISTS mailbox;
ALTER TABLE mailboxtmp RENAME TO mailbox;
CREATE INDEX IF NOT EXISTS idx_created ON mailbox (Created);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_message_id ON mailbox (MessageID);
CREATE INDEX IF NOT EXISTS idx_subject ON mailbox (Subject);
CREATE INDEX IF NOT EXISTS idx_size ON mailbox (Size);
CREATE INDEX IF NOT EXISTS idx_inline ON mailbox (Inline);
CREATE INDEX IF NOT EXISTS idx_attachments ON mailbox (Attachments);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE INDEX IF NOT EXISTS idx_tags ON mailbox (Tags);`,
},
{
Version: 1.3,
Description: "Create snippet column",
Script: `ALTER TABLE mailbox ADD COLUMN Snippet Text NOT NULL DEFAULT '';`,
},
{
Version: 1.4,
Description: "Create tag tables",
Script: `CREATE TABLE IF NOT EXISTS tags (
ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Name TEXT COLLATE NOCASE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tag_name ON tags (Name);
CREATE TABLE IF NOT EXISTS message_tags(
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ID TEXT REFERENCES mailbox(ID),
TagID INT REFERENCES tags(ID)
);
CREATE INDEX IF NOT EXISTS idx_message_tag_id ON message_tags (ID);
CREATE INDEX IF NOT EXISTS idx_message_tag_tagid ON message_tags (TagID);`,
},
{
// assume deleted messages account for 50% of storage
// to handle previously-deleted messages
Version: 1.5,
Description: "Create settings table",
Script: `CREATE TABLE IF NOT EXISTS settings (
Key TEXT,
Value TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_key ON settings (Key);
INSERT INTO settings (Key, Value) VALUES("DeletedSize", (SELECT SUM(Size)/2 FROM mailbox));`,
},
}
)
// Create tables and apply migrations if required
func dbApplyMigrations() error {
driver := darwin.NewGenericDriver(db, darwin.SqliteDialect{})
d := darwin.New(driver, dbMigrations, nil)
return d.Migrate()
}
// These functions are used to migrate data formats/structure on startup.
func dataMigrations() {
// ensure DeletedSize has a value if empty
if SettingGet("DeletedSize") == "" {
_ = SettingPut("DeletedSize", "0")
}
migrateTagsToManyMany()
}
// Migrate tags to ManyMany structure
// Migration task implemented 12/2023
// Can be removed end 06/2024 and Tags column & index dropped from mailbox
func migrateTagsToManyMany() {
toConvert := make(map[string][]string)
q := sqlf.
Select("ID, Tags").
From("mailbox").
Where("Tags != ?", "[]").
Where("Tags IS NOT NULL")
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
var jsonTags string
if err := row.Scan(&id, &jsonTags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
return
}
tags := []string{}
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
logger.Log().Errorf("[json] %s", err.Error())
return
}
toConvert[id] = tags
}); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
if len(toConvert) > 0 {
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
for id, tags := range toConvert {
if err := SetMessageTags(id, tags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
} else {
if _, err := sqlf.Update("mailbox").
Set("Tags", nil).
Where("ID = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}
}
logger.Log().Info("[migration] tags conversion complete")
}
// set all legacy `[]` tags to NULL
if _, err := sqlf.Update("mailbox").
Set("Tags", nil).
Where("Tags = ?", "[]").
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}

View File

@ -24,8 +24,8 @@ func BroadcastMailboxStats() {
time.Sleep(250 * time.Millisecond) time.Sleep(250 * time.Millisecond)
bcStatsDelay = false bcStatsDelay = false
b := struct { b := struct {
Total int Total float64
Unread int Unread float64
Version string Version string
}{ }{
Total: CountTotal(), Total: CountTotal(),

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt"
"net/mail" "net/mail"
"os" "os"
@ -24,7 +25,7 @@ func ReindexAll() {
finished := 0 finished := 0
err := sqlf.Select("ID").To(&i). err := sqlf.Select("ID").To(&i).
From("mailbox"). From(tenant("mailbox")).
OrderBy("Created DESC"). OrderBy("Created DESC").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) { QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
ids = append(ids, i) ids = append(ids, i)
@ -112,7 +113,7 @@ func ReindexAll() {
// insert mail summary data // insert mail summary data
for _, u := range updates { for _, u := range updates {
_, err = tx.Exec("UPDATE mailbox SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?", u.SearchText, u.Snippet, u.Metadata, u.ID) _, err = tx.Exec(fmt.Sprintf(`UPDATE %s SET SearchText = ?, Snippet = ?, Metadata = ? WHERE ID = ?`, tenant("mailbox")), u.SearchText, u.Snippet, u.Metadata, u.ID)
if err != nil { if err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
continue continue

222
internal/storage/schemas.go Normal file
View File

@ -0,0 +1,222 @@
package storage
import (
"bytes"
"context"
"database/sql"
"embed"
"encoding/json"
"log"
"path"
"sort"
"strings"
"text/template"
"time"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/semver"
"github.com/leporo/sqlf"
)
//go:embed schemas/*
var schemaScripts embed.FS
// Create tables and apply schemas if required
func dbApplySchemas() error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS ` + tenant("schemas") + ` (Version TEXT PRIMARY KEY NOT NULL)`); err != nil {
return err
}
var legacyMigrationTable int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, tenant("darwin_migrations")).Scan(&legacyMigrationTable)
if err != nil {
return err
}
if legacyMigrationTable == 1 {
rows, err := db.Query(`SELECT version FROM ` + tenant("darwin_migrations"))
if err != nil {
return err
}
legacySchemas := []string{}
for rows.Next() {
var oldID string
if err := rows.Scan(&oldID); err == nil {
legacySchemas = append(legacySchemas, semver.MajorMinor(oldID)+"."+semver.Patch(oldID))
}
}
legacySchemas = semver.SortMin(legacySchemas)
for _, v := range legacySchemas {
var migrated int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, v).Scan(&migrated)
if err != nil {
return err
}
if migrated == 0 {
// copy to tenant("schemas")
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, v); err != nil {
return err
}
}
}
// delete legacy migration database after 01/10/2024
if time.Now().After(time.Date(2024, 10, 1, 0, 0, 0, 0, time.Local)) {
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + tenant("darwin_migrations")); err != nil {
return err
}
}
}
schemaFiles, err := schemaScripts.ReadDir("schemas")
if err != nil {
log.Fatal(err)
}
temp := template.New("")
temp.Funcs(
template.FuncMap{
"tenant": tenant,
},
)
type schema struct {
Name string
Semver string
}
scripts := []schema{}
for _, s := range schemaFiles {
if !s.Type().IsRegular() || !strings.HasSuffix(s.Name(), ".sql") {
continue
}
schemaID := strings.TrimRight(s.Name(), ".sql")
if !semver.IsValid(schemaID) {
logger.Log().Warnf("[db] invalid schema name: %s", s.Name())
continue
}
script := schema{s.Name(), semver.MajorMinor(schemaID) + "." + semver.Patch(schemaID)}
scripts = append(scripts, script)
}
// sort schemas by semver, low to high
sort.Slice(scripts, func(i, j int) bool {
return semver.Compare(scripts[j].Semver, scripts[i].Semver) == 1
})
for _, s := range scripts {
var complete int
err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM `+tenant("schemas")+` WHERE Version = ?)`, s.Semver).Scan(&complete)
if err != nil {
return err
}
if complete == 1 {
// already completed, ignore
continue
}
// use path.Join for Windows compatibility, see https://github.com/golang/go/issues/44305
b, err := schemaScripts.ReadFile(path.Join("schemas", s.Name))
if err != nil {
return err
}
// parse import script
t1, err := temp.Parse(string(b))
if err != nil {
return err
}
buf := new(bytes.Buffer)
err = t1.Execute(buf, nil)
if _, err := db.Exec(buf.String()); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO `+tenant("schemas")+` (Version) VALUES (?)`, s.Semver); err != nil {
return err
}
logger.Log().Debugf("[db] applied schema: %s", s.Name)
}
return nil
}
// These functions are used to migrate data formats/structure on startup.
func dataMigrations() {
// ensure DeletedSize has a value if empty
if SettingGet("DeletedSize") == "" {
_ = SettingPut("DeletedSize", "0")
}
migrateTagsToManyMany()
}
// Migrate tags to ManyMany structure
// Migration task implemented 12/2023
// TODO: Can be removed end 06/2024 and Tags column & index dropped from mailbox
func migrateTagsToManyMany() {
toConvert := make(map[string][]string)
q := sqlf.
Select("ID, Tags").
From(tenant("mailbox")).
Where("Tags != ?", "[]").
Where("Tags IS NOT NULL")
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var id string
var jsonTags string
if err := row.Scan(&id, &jsonTags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
return
}
tags := []string{}
if err := json.Unmarshal([]byte(jsonTags), &tags); err != nil {
logger.Log().Errorf("[json] %s", err.Error())
return
}
toConvert[id] = tags
}); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
if len(toConvert) > 0 {
logger.Log().Infof("[migration] converting %d message tags", len(toConvert))
for id, tags := range toConvert {
if err := SetMessageTags(id, tags); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
} else {
if _, err := sqlf.Update(tenant("mailbox")).
Set("Tags", nil).
Where("ID = ?", id).
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}
}
logger.Log().Info("[migration] tags conversion complete")
}
// set all legacy `[]` tags to NULL
if _, err := sqlf.Update(tenant("mailbox")).
Set("Tags", nil).
Where("Tags = ?", "[]").
ExecAndClose(context.TODO(), db); err != nil {
logger.Log().Errorf("[migration] %s", err.Error())
}
}

View File

@ -0,0 +1,19 @@
-- CREATE TABLES
CREATE TABLE IF NOT EXISTS {{ tenant "mailbox" }} (
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
Data BLOB,
Search TEXT,
Read INTEGER
);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_sort" }} ON {{ tenant "mailbox" }} (Sort);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read);
CREATE TABLE IF NOT EXISTS {{ tenant "mailbox_data" }} (
ID TEXT KEY NOT NULL,
Email BLOB
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_data_id" }} ON {{ tenant "mailbox_data" }} (ID);

View File

@ -0,0 +1,3 @@
-- CREATE TAGS COLUMN
ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Tags Text NOT NULL DEFAULT '[]';
CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags);

View File

@ -0,0 +1,36 @@
-- CREATING NEW MAILBOX FORMAT
CREATE TABLE IF NOT EXISTS {{ tenant "mailboxtmp" }} (
Created INTEGER NOT NULL,
ID TEXT NOT NULL,
MessageID TEXT NOT NULL,
Subject TEXT NOT NULL,
Metadata TEXT,
Size INTEGER NOT NULL,
Inline INTEGER NOT NULL,
Attachments INTEGER NOT NULL,
Read INTEGER,
Tags TEXT,
SearchText TEXT
);
INSERT INTO {{ tenant "mailboxtmp" }}
(Created, ID, MessageID, Subject, Metadata, Size, Inline, Attachments, SearchText, Read, Tags)
SELECT
Sort, ID, '', json_extract(Data, '$.Subject'),Data,
json_extract(Data, '$.Size'), json_extract(Data, '$.Inline'), json_extract(Data, '$.Attachments'),
Search, Read, Tags
FROM {{ tenant "mailbox" }};
DROP TABLE IF EXISTS {{ tenant "mailbox" }};
ALTER TABLE {{ tenant "mailboxtmp" }} RENAME TO {{ tenant "mailbox" }};
CREATE INDEX IF NOT EXISTS {{ tenant "idx_created" }} ON {{ tenant "mailbox" }} (Created);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_id" }} ON {{ tenant "mailbox" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_id" }} ON {{ tenant "mailbox" }} (MessageID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_subject" }} ON {{ tenant "mailbox" }} (Subject);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_size" }} ON {{ tenant "mailbox" }} (Size);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_inline" }} ON {{ tenant "mailbox" }} (Inline);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_attachments" }} ON {{ tenant "mailbox" }} (Attachments);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_read" }} ON {{ tenant "mailbox" }} (Read);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_tags" }} ON {{ tenant "mailbox" }} (Tags);

View File

@ -0,0 +1,2 @@
-- CREATE SNIPPET COLUMN
ALTER TABLE {{ tenant "mailbox" }} ADD COLUMN Snippet TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,16 @@
-- CREATE TAG TABLES
CREATE TABLE IF NOT EXISTS {{ tenant "tags" }} (
ID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Name TEXT COLLATE NOCASE
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_tag_name" }} ON {{ tenant "tags" }} (Name);
CREATE TABLE IF NOT EXISTS {{ tenant "message_tags" }} (
Key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ID TEXT REFERENCES {{ tenant "mailbox" }} (ID),
TagID INT REFERENCES {{ tenant "tags" }} (ID)
);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_id" }} ON {{ tenant "message_tags" }} (ID);
CREATE INDEX IF NOT EXISTS {{ tenant "idx_message_tag_tagid" }} ON {{ tenant "message_tags" }} (TagID);

View File

@ -0,0 +1,7 @@
-- CREATE SETTINGS TABLE
CREATE TABLE IF NOT EXISTS {{ tenant "settings" }} (
Key TEXT,
Value TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS {{ tenant "idx_settings_key" }} ON {{ tenant "settings" }} (Key);
INSERT INTO {{ tenant "settings" }} (Key, Value) VALUES ("DeletedSize", (SELECT SUM(Size)/2 FROM {{ tenant "mailbox" }}));

View File

@ -0,0 +1,5 @@
# Migration scripts
- Scripts should be named using semver and have the `.sql` extension.
- Inline comments should be prefixed with a `--`
- All references to tables and indexes should be wrapped with a `{{ tenant "<name>" }}`

View File

@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/araddon/dateparse"
"github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/tools" "github.com/axllent/mailpit/internal/tools"
"github.com/leporo/sqlf" "github.com/leporo/sqlf"
@ -17,7 +18,7 @@ import (
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as: // 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> // 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 `!` // Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string, start, limit int) ([]MessageSummary, int, error) { func Search(search, timezone string, start, limit int) ([]MessageSummary, int, error) {
results := []MessageSummary{} results := []MessageSummary{}
allResults := []MessageSummary{} allResults := []MessageSummary{}
tsStart := time.Now() tsStart := time.Now()
@ -26,16 +27,16 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
limit = 50 limit = 50
} }
q := searchQueryBuilder(search) q := searchQueryBuilder(search, timezone)
var err error var err error
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created int64 var created float64
var id string var id string
var messageID string var messageID string
var subject string var subject string
var metadata string var metadata string
var size int var size float64
var attachments int var attachments int
var snippet string var snippet string
var read int var read int
@ -52,7 +53,7 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
return return
} }
em.Created = time.UnixMilli(created) em.Created = time.UnixMilli(int64(created))
em.ID = id em.ID = id
em.MessageID = messageID em.MessageID = messageID
em.Subject = subject em.Subject = subject
@ -95,21 +96,20 @@ func Search(search string, start, limit int) ([]MessageSummary, int, error) {
// The search is broken up by segments (exact phrases can be quoted), and interprets specific terms such as: // 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> // 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 `!` // Negative searches also also included by prefixing the search term with a `-` or `!`
func DeleteSearch(search string) error { func DeleteSearch(search, timezone string) error {
q := searchQueryBuilder(search) q := searchQueryBuilder(search, timezone)
ids := []string{} ids := []string{}
deleteSize := 0 deleteSize := float64(0)
if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) { if err := q.QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
var created int64 var created float64
var id string var id string
var messageID string var messageID string
var subject string var subject string
var metadata string var metadata string
var size int var size float64
var attachments int var attachments int
// var tags string
var read int var read int
var snippet string var snippet string
var ignore string var ignore string
@ -160,21 +160,21 @@ func DeleteSearch(search string) error {
delIDs[i] = id delIDs[i] = id
} }
sqlDelete1 := `DELETE FROM mailbox WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec sqlDelete1 := `DELETE FROM ` + tenant("mailbox") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete1, delIDs...) _, err = tx.Exec(sqlDelete1, delIDs...)
if err != nil { if err != nil {
return err return err
} }
sqlDelete2 := `DELETE FROM mailbox_data WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec sqlDelete2 := `DELETE FROM ` + tenant("mailbox_data") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete2, delIDs...) _, err = tx.Exec(sqlDelete2, delIDs...)
if err != nil { if err != nil {
return err return err
} }
sqlDelete3 := `DELETE FROM message_tags WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec sqlDelete3 := `DELETE FROM ` + tenant("message_tags") + ` WHERE ID IN (?` + strings.Repeat(",?", len(ids)-1) + `)` // #nosec
_, err = tx.Exec(sqlDelete3, delIDs...) _, err = tx.Exec(sqlDelete3, delIDs...)
if err != nil { if err != nil {
@ -204,11 +204,20 @@ func DeleteSearch(search string) error {
} }
// SearchParser returns the SQL syntax for the database search based on the search arguments // SearchParser returns the SQL syntax for the database search based on the search arguments
func searchQueryBuilder(searchString string) *sqlf.Stmt { func searchQueryBuilder(searchString, timezone string) *sqlf.Stmt {
// group strings with quotes as a single argument and remove quotes // group strings with quotes as a single argument and remove quotes
args := tools.ArgsParser(searchString) args := tools.ArgsParser(searchString)
q := sqlf.From("mailbox m"). if timezone != "" {
loc, err := time.LoadLocation(timezone)
if err != nil {
logger.Log().Warnf("ignoring invalid timezone:\"%s\"", timezone)
} else {
time.Local = loc
}
}
q := sqlf.From(tenant("mailbox") + " m").
Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read, Select(`m.Created, m.ID, m.MessageID, m.Subject, m.Metadata, m.Size, m.Attachments, m.Read,
m.Snippet, m.Snippet,
IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON, IFNULL(json_extract(Metadata, '$.To'), '{}') as ToJSON,
@ -307,9 +316,9 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
w = cleanString(w[4:]) w = cleanString(w[4:])
if w != "" { if w != "" {
if exclude { if exclude {
q.Where(`m.ID NOT IN (SELECT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID WHERE t.Name = ?)`, w) q.Where(`m.ID NOT IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
} else { } else {
q.Where(`m.ID IN (SELECT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID WHERE t.Name = ?)`, w) q.Where(`m.ID IN (SELECT mt.ID FROM `+tenant("message_tags")+` mt JOIN `+tenant("tags")+` t ON mt.TagID = t.ID WHERE t.Name = ?)`, w)
} }
} }
} else if lw == "is:read" { } else if lw == "is:read" {
@ -326,9 +335,9 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
} }
} else if lw == "is:tagged" { } else if lw == "is:tagged" {
if exclude { if exclude {
q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`) q.Where(`m.ID NOT IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
} else { } else {
q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM message_tags mt JOIN tags t ON mt.TagID = t.ID)`) q.Where(`m.ID IN (SELECT DISTINCT mt.ID FROM ` + tenant("message_tags") + ` mt JOIN tags t ON mt.TagID = t.ID)`)
} }
} else if lw == "has:attachment" || lw == "has:attachments" { } else if lw == "has:attachment" || lw == "has:attachments" {
if exclude { if exclude {
@ -336,6 +345,36 @@ func searchQueryBuilder(searchString string) *sqlf.Stmt {
} else { } else {
q.Where("Attachments > 0") q.Where("Attachments > 0")
} }
} else if strings.HasPrefix(lw, "after:") {
w = cleanString(w[6:])
if w != "" {
t, err := dateparse.ParseLocal(w)
if err != nil {
logger.Log().Warnf("ignoring invalid after: date \"%s\"", w)
} else {
timestamp := t.UnixMilli()
if exclude {
q.Where(`m.Created <= ?`, timestamp)
} else {
q.Where(`m.Created >= ?`, timestamp)
}
}
}
} else if strings.HasPrefix(lw, "before:") {
w = cleanString(w[7:])
if w != "" {
t, err := dateparse.ParseLocal(w)
if err != nil {
logger.Log().Warnf("ignoring invalid before: date \"%s\"", w)
} else {
timestamp := t.UnixMilli()
if exclude {
q.Where(`m.Created >= ?`, timestamp)
} else {
q.Where(`m.Created <= ?`, timestamp)
}
}
}
} else { } else {
// search text // search text
if exclude { if exclude {

View File

@ -69,7 +69,7 @@ func TestSearch(t *testing.T) {
search := uniqueSearches[searchIdx] search := uniqueSearches[searchIdx]
summaries, _, err := Search(search, 0, 100) summaries, _, err := Search(search, "", 0, 100)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
@ -85,7 +85,7 @@ func TestSearch(t *testing.T) {
} }
// search something that will return 200 results // 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, testRuns)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() 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, 100)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
@ -117,12 +117,12 @@ func TestSearchDelete100(t *testing.T) {
assertEqual(t, total, 100, "100 search results expected") assertEqual(t, total, 100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com"); err != nil { if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
} }
_, total, err = Search("from:sender@example.com", 0, 100) _, total, err = Search("from:sender@example.com", "", 0, 100)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() 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, 100)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
@ -151,12 +151,12 @@ func TestSearchDelete1100(t *testing.T) {
assertEqual(t, total, 1100, "100 search results expected") assertEqual(t, total, 1100, "100 search results expected")
if err := DeleteSearch("from:sender@example.com"); err != nil { if err := DeleteSearch("from:sender@example.com", ""); err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()
} }
_, total, err = Search("from:sender@example.com", 0, 100) _, total, err = Search("from:sender@example.com", "", 0, 100)
if err != nil { if err != nil {
t.Log("error ", err) t.Log("error ", err)
t.Fail() t.Fail()

View File

@ -11,7 +11,7 @@ import (
// SettingGet returns a setting string value, blank is it does not exist // SettingGet returns a setting string value, blank is it does not exist
func SettingGet(k string) string { func SettingGet(k string) string {
var result sql.NullString var result sql.NullString
err := sqlf.From("settings"). err := sqlf.From(tenant("settings")).
Select("Value").To(&result). Select("Value").To(&result).
Where("Key = ?", k). Where("Key = ?", k).
Limit(1). Limit(1).
@ -26,7 +26,7 @@ func SettingGet(k string) string {
// SettingPut sets a setting string value, inserting if new // SettingPut sets a setting string value, inserting if new
func SettingPut(k, v string) error { func SettingPut(k, v string) error {
_, err := db.Exec("INSERT INTO settings (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?", k, v, v) _, err := db.Exec(`INSERT INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?) ON CONFLICT(Key) DO UPDATE SET Value = ?`, k, v, v)
if err != nil { if err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
} }
@ -35,9 +35,9 @@ func SettingPut(k, v string) error {
} }
// The total deleted message size as an int64 value // The total deleted message size as an int64 value
func getDeletedSize() int64 { func getDeletedSize() float64 {
var result sql.NullInt64 var result sql.NullFloat64
err := sqlf.From("settings"). err := sqlf.From(tenant("settings")).
Select("Value").To(&result). Select("Value").To(&result).
Where("Key = ?", "DeletedSize"). Where("Key = ?", "DeletedSize").
Limit(1). Limit(1).
@ -47,13 +47,13 @@ func getDeletedSize() int64 {
return 0 return 0
} }
return result.Int64 return result.Float64
} }
// The total raw non-compressed messages size in bytes of all messages in the database // The total raw non-compressed messages size in bytes of all messages in the database
func totalMessagesSize() int64 { func totalMessagesSize() float64 {
var result sql.NullInt64 var result sql.NullFloat64
err := sqlf.From("mailbox"). err := sqlf.From(tenant("mailbox")).
Select("SUM(Size)").To(&result). Select("SUM(Size)").To(&result).
QueryAndClose(context.TODO(), db, func(row *sql.Rows) {}) QueryAndClose(context.TODO(), db, func(row *sql.Rows) {})
if err != nil { if err != nil {
@ -61,16 +61,16 @@ func totalMessagesSize() int64 {
return 0 return 0
} }
return result.Int64 return result.Float64
} }
// AddDeletedSize will add the value to the DeletedSize setting // AddDeletedSize will add the value to the DeletedSize setting
func addDeletedSize(v int64) { func addDeletedSize(v int64) {
if _, err := db.Exec("INSERT OR IGNORE INTO settings (Key, Value) VALUES(?, ?)", "DeletedSize", 0); err != nil { if _, err := db.Exec(`INSERT OR IGNORE INTO `+tenant("settings")+` (Key, Value) VALUES(?, ?)`, "DeletedSize", 0); err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
} }
if _, err := db.Exec("UPDATE settings SET Value = Value + ? WHERE Key = ?", v, "DeletedSize"); err != nil { if _, err := db.Exec(`UPDATE `+tenant("settings")+` SET Value = Value + ? WHERE Key = ?`, v, "DeletedSize"); err != nil {
logger.Log().Errorf("[db] %s", err.Error()) logger.Log().Errorf("[db] %s", err.Error())
} }
} }

View File

@ -41,7 +41,7 @@ type Message struct {
// Message body HTML // Message body HTML
HTML string HTML string
// Message size in bytes // Message size in bytes
Size int Size float64
// Inline message attachments // Inline message attachments
Inline []Attachment Inline []Attachment
// Message attachments // Message attachments
@ -61,7 +61,7 @@ type Attachment struct {
// Content ID // Content ID
ContentID string ContentID string
// Size in bytes // Size in bytes
Size int Size float64
} }
// MessageSummary struct for frontend messages // MessageSummary struct for frontend messages
@ -91,7 +91,7 @@ type MessageSummary struct {
// Message tags // Message tags
Tags []string Tags []string
// Message size in bytes (total) // Message size in bytes (total)
Size int Size float64
// Whether the message has any attachments // Whether the message has any attachments
Attachments int Attachments int
// Message snippet includes up to 250 characters // Message snippet includes up to 250 characters
@ -100,8 +100,8 @@ type MessageSummary struct {
// MailboxStats struct for quick mailbox total/read lookups // MailboxStats struct for quick mailbox total/read lookups
type MailboxStats struct { type MailboxStats struct {
Total int Total float64
Unread int Unread float64
Tags []string Tags []string
} }
@ -124,7 +124,7 @@ func AttachmentSummary(a *enmime.Part) Attachment {
} }
o.ContentType = a.ContentType o.ContentType = a.ContentType
o.ContentID = a.ContentID o.ContentID = a.ContentID
o.Size = len(a.Content) o.Size = float64(len(a.Content))
return o return o
} }

View File

@ -60,7 +60,7 @@ func SetMessageTags(id string, tags []string) error {
func AddMessageTag(id, name string) error { func AddMessageTag(id, name string) error {
var tagID int var tagID int
q := sqlf.From("tags"). q := sqlf.From(tenant("tags")).
Select("ID").To(&tagID). Select("ID").To(&tagID).
Where("Name = ?", name) Where("Name = ?", name)
@ -68,7 +68,7 @@ func AddMessageTag(id, name string) error {
if err := q.QueryRowAndClose(context.TODO(), db); err == nil { if err := q.QueryRowAndClose(context.TODO(), db); err == nil {
// check message does not already have this tag // check message does not already have this tag
var count int var count int
if _, err := sqlf.From("message_tags"). if _, err := sqlf.From(tenant("message_tags")).
Select("COUNT(ID)").To(&count). Select("COUNT(ID)").To(&count).
Where("ID = ?", id). Where("ID = ?", id).
Where("TagID = ?", tagID). Where("TagID = ?", tagID).
@ -82,7 +82,7 @@ func AddMessageTag(id, name string) error {
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id) logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
_, err := sqlf.InsertInto("message_tags"). _, err := sqlf.InsertInto(tenant("message_tags")).
Set("ID", id). Set("ID", id).
Set("TagID", tagID). Set("TagID", tagID).
ExecAndClose(context.TODO(), db) ExecAndClose(context.TODO(), db)
@ -92,39 +92,20 @@ func AddMessageTag(id, name string) error {
logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id) logger.Log().Debugf("[tags] adding tag \"%s\" to %s", name, id)
// tag dos not exist, add new one // tag dos not exist, add new one
if err := sqlf.InsertInto("tags"). if _, err := sqlf.InsertInto(tenant("tags")).
Set("Name", name). Set("Name", name).
Returning("ID").To(&tagID).
QueryRowAndClose(context.TODO(), db); err != nil {
return err
}
// check message does not already have this tag
var count int
if _, err := sqlf.From("message_tags").
Select("COUNT(ID)").To(&count).
Where("ID = ?", id).
Where("TagID = ?", tagID).
ExecAndClose(context.TODO(), db); err != nil { ExecAndClose(context.TODO(), db); err != nil {
return err return err
} }
if count != 0 {
return nil // already exists
}
// add tag to message return AddMessageTag(id, name)
_, err := sqlf.InsertInto("message_tags").
Set("ID", id).
Set("TagID", tagID).
ExecAndClose(context.TODO(), db)
return err
} }
// DeleteMessageTag deleted a tag from a message // DeleteMessageTag deleted a tag from a message
func DeleteMessageTag(id, name string) error { func DeleteMessageTag(id, name string) error {
if _, err := sqlf.DeleteFrom("message_tags"). if _, err := sqlf.DeleteFrom(tenant("message_tags")).
Where("message_tags.ID = ?", id). Where(tenant("message_tags.ID")+" = ?", id).
Where(`message_tags.Key IN (SELECT Key FROM message_tags LEFT JOIN tags ON TagID=tags.ID WHERE Name = ?)`, name). Where(tenant("message_tags.Key")+` IN (SELECT Key FROM `+tenant("message_tags")+` LEFT JOIN tags ON `+tenant("TagID")+"="+tenant("tags.ID")+` WHERE Name = ?)`, name).
ExecAndClose(context.TODO(), db); err != nil { ExecAndClose(context.TODO(), db); err != nil {
return err return err
} }
@ -134,8 +115,8 @@ func DeleteMessageTag(id, name string) error {
// DeleteAllMessageTags deleted all tags from a message // DeleteAllMessageTags deleted all tags from a message
func DeleteAllMessageTags(id string) error { func DeleteAllMessageTags(id string) error {
if _, err := sqlf.DeleteFrom("message_tags"). if _, err := sqlf.DeleteFrom(tenant("message_tags")).
Where("message_tags.ID = ?", id). Where(tenant("message_tags.ID")+" = ?", id).
ExecAndClose(context.TODO(), db); err != nil { ExecAndClose(context.TODO(), db); err != nil {
return err return err
} }
@ -150,7 +131,7 @@ func GetAllTags() []string {
if err := sqlf. if err := sqlf.
Select(`DISTINCT Name`). Select(`DISTINCT Name`).
From("tags").To(&name). From(tenant("tags")).To(&name).
OrderBy("Name"). OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) { QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags = append(tags, name) tags = append(tags, name)
@ -169,10 +150,10 @@ func GetAllTagsCount() map[string]int64 {
if err := sqlf. if err := sqlf.
Select(`Name`).To(&name). Select(`Name`).To(&name).
Select(`COUNT(message_tags.TagID) as total`).To(&total). Select(`COUNT(`+tenant("message_tags.TagID")+`) as total`).To(&total).
From("tags"). From(tenant("tags")).
LeftJoin("message_tags", "tags.ID = message_tags.TagID"). LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
GroupBy("message_tags.TagID"). GroupBy(tenant("message_tags.TagID")).
OrderBy("Name"). OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) { QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags[name] = total tags[name] = total
@ -186,10 +167,10 @@ func GetAllTagsCount() map[string]int64 {
// PruneUnusedTags will delete all unused tags from the database // PruneUnusedTags will delete all unused tags from the database
func pruneUnusedTags() error { func pruneUnusedTags() error {
q := sqlf.From("tags"). q := sqlf.From(tenant("tags")).
Select("tags.ID, tags.Name, COUNT(message_tags.ID) as COUNT"). Select(tenant("tags.ID")+", "+tenant("tags.Name")+", COUNT("+tenant("message_tags.ID")+") as COUNT").
LeftJoin("message_tags", "tags.ID = message_tags.TagID"). LeftJoin(tenant("message_tags"), tenant("tags.ID")+" = "+tenant("message_tags.TagID")).
GroupBy("tags.ID") GroupBy(tenant("tags.ID"))
toDel := []int{} toDel := []int{}
@ -213,7 +194,7 @@ func pruneUnusedTags() error {
if len(toDel) > 0 { if len(toDel) > 0 {
for _, id := range toDel { for _, id := range toDel {
if _, err := sqlf.DeleteFrom("tags"). if _, err := sqlf.DeleteFrom(tenant("tags")).
Where("ID = ?", id). Where("ID = ?", id).
ExecAndClose(context.TODO(), db); err != nil { ExecAndClose(context.TODO(), db); err != nil {
return err return err
@ -246,24 +227,24 @@ func findTagsInRawMessage(message *[]byte) string {
func (d DBMailSummary) tagsFromPlusAddresses() string { func (d DBMailSummary) tagsFromPlusAddresses() string {
tags := []string{} tags := []string{}
for _, c := range d.To { for _, c := range d.To {
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1) matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 { if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...) tags = append(tags, strings.Split(matches[0][2], "+")...)
} }
} }
for _, c := range d.Cc { for _, c := range d.Cc {
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1) matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 { if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...) tags = append(tags, strings.Split(matches[0][2], "+")...)
} }
} }
for _, c := range d.Bcc { for _, c := range d.Bcc {
matches := addressPlusRe.FindAllStringSubmatch(c.String(), 1) matches := addressPlusRe.FindAllStringSubmatch(c.Address, 1)
if len(matches) == 1 { if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...) tags = append(tags, strings.Split(matches[0][2], "+")...)
} }
} }
matches := addressPlusRe.FindAllStringSubmatch(d.From.String(), 1) matches := addressPlusRe.FindAllStringSubmatch(d.From.Address, 1)
if len(matches) == 1 { if len(matches) == 1 {
tags = append(tags, strings.Split(matches[0][2], "+")...) tags = append(tags, strings.Split(matches[0][2], "+")...)
} }
@ -279,9 +260,9 @@ func getMessageTags(id string) []string {
if err := sqlf. if err := sqlf.
Select(`Name`).To(&name). Select(`Name`).To(&name).
From("Tags"). From(tenant("Tags")).
LeftJoin("message_tags", "Tags.ID=message_tags.TagID"). LeftJoin(tenant("message_tags"), tenant("Tags.ID")+"="+tenant("message_tags.TagID")).
Where(`message_tags.ID = ?`, id). Where(tenant("message_tags.ID")+` = ?`, id).
OrderBy("Name"). OrderBy("Name").
QueryAndClose(context.TODO(), db, func(row *sql.Rows) { QueryAndClose(context.TODO(), db, func(row *sql.Rows) {
tags = append(tags, name) tags = append(tags, name)

View File

@ -19,7 +19,7 @@ var (
func setup() { func setup() {
logger.NoLogging = true logger.NoLogging = true
config.MaxMessages = 0 config.MaxMessages = 0
config.DataFile = "" config.Database = os.Getenv("MP_DATABASE")
if err := InitDB(); err != nil { if err := InitDB(); err != nil {
panic(err) panic(err)
@ -27,6 +27,11 @@ func setup() {
var err error var err error
// ensure DB is empty
if err := DeleteAllMessages(); err != nil {
panic(err)
}
testTextEmail, err = os.ReadFile("testdata/plain-text.eml") testTextEmail, err = os.ReadFile("testdata/plain-text.eml")
if err != nil { if err != nil {
panic(err) panic(err)
@ -53,11 +58,11 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
func assertEqualStats(t *testing.T, total int, unread int) { func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet() s := StatsGet()
if total != s.Total { if float64(total) != s.Total {
t.Fatalf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total) t.Fatalf("Incorrect total mailbox stats: \"%v\" != \"%v\"", total, s.Total)
} }
if unread != s.Unread { if float64(unread) != s.Unread {
t.Fatalf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread) t.Fatalf("Incorrect unread mailbox stats: \"%v\" != \"%v\"", unread, s.Unread)
} }
} }

View File

@ -15,7 +15,7 @@ var (
// for stats to prevent import cycle // for stats to prevent import cycle
mu sync.RWMutex mu sync.RWMutex
// StatsDeleted for counting the number of messages deleted // StatsDeleted for counting the number of messages deleted
StatsDeleted int StatsDeleted float64
) )
// Return a header field as a []*mail.Address, or "null" is not found/empty // Return a header field as a []*mail.Address, or "null" is not found/empty
@ -73,7 +73,7 @@ func cleanString(str string) string {
// LogMessagesDeleted logs the number of messages deleted // LogMessagesDeleted logs the number of messages deleted
func logMessagesDeleted(n int) { func logMessagesDeleted(n int) {
mu.Lock() mu.Lock()
StatsDeleted = StatsDeleted + n StatsDeleted = StatsDeleted + float64(n)
mu.Unlock() mu.Unlock()
} }

1394
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -67,7 +67,7 @@ func GetMessages(w http.ResponseWriter, r *http.Request) {
res.Start = start res.Start = start
res.Messages = messages res.Messages = messages
res.Count = len(messages) // legacy - now undocumented in API specs res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total res.Total = stats.Total
res.Unread = stats.Unread res.Unread = stats.Unread
res.Tags = stats.Tags res.Tags = stats.Tags
@ -109,6 +109,11 @@ func Search(w http.ResponseWriter, r *http.Request) {
// required: false // required: false
// type: integer // type: integer
// default: 50 // default: 50
// + name: tz
// in: query
// description: Timezone for `before:` & `after:` queries, eg: "Pacific/Auckland"
// required: false
// type: string
// //
// Responses: // Responses:
// 200: MessagesSummaryResponse // 200: MessagesSummaryResponse
@ -121,7 +126,7 @@ func Search(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r) start, limit := getStartLimit(r)
messages, results, err := storage.Search(search, start, limit) messages, results, err := storage.Search(search, r.URL.Query().Get("tz"), start, limit)
if err != nil { if err != nil {
httpError(w, err.Error()) httpError(w, err.Error())
return return
@ -133,9 +138,9 @@ func Search(w http.ResponseWriter, r *http.Request) {
res.Start = start res.Start = start
res.Messages = messages res.Messages = messages
res.Count = len(messages) // legacy - now undocumented in API specs res.Count = float64(len(messages)) // legacy - now undocumented in API specs
res.Total = stats.Total // total messages in mailbox res.Total = stats.Total // total messages in mailbox
res.MessagesCount = results res.MessagesCount = float64(results)
res.Unread = stats.Unread res.Unread = stats.Unread
res.Tags = stats.Tags res.Tags = stats.Tags
@ -173,7 +178,7 @@ func DeleteSearch(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := storage.DeleteSearch(search); err != nil { if err := storage.DeleteSearch(search, r.URL.Query().Get("tz")); err != nil {
httpError(w, err.Error()) httpError(w, err.Error())
return return
} }
@ -337,7 +342,11 @@ func GetHeaders(w http.ResponseWriter, r *http.Request) {
return return
} }
bytes, _ := json.Marshal(m.Header) bytes, err := json.Marshal(m.Header)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
_, _ = w.Write(bytes) _, _ = w.Write(bytes)
@ -428,11 +437,9 @@ func DeleteMessages(w http.ResponseWriter, r *http.Request) {
return return
} }
} else { } else {
for _, id := range data.IDs { if err := storage.DeleteMessages(data.IDs); err != nil {
if err := storage.DeleteOneMessage(id); err != nil { httpError(w, err.Error())
httpError(w, err.Error()) return
return
}
} }
} }

View File

@ -10,18 +10,18 @@ import (
// MessagesSummary is a summary of a list of messages // MessagesSummary is a summary of a list of messages
type MessagesSummary struct { type MessagesSummary struct {
// Total number of messages in mailbox // Total number of messages in mailbox
Total int `json:"total"` Total float64 `json:"total"`
// Total number of unread messages in mailbox // Total number of unread messages in mailbox
Unread int `json:"unread"` Unread float64 `json:"unread"`
// Legacy - now undocumented in API specs but left for backwards compatibility. // Legacy - now undocumented in API specs but left for backwards compatibility.
// Removed from API documentation 2023-07-12 // Removed from API documentation 2023-07-12
// swagger:ignore // swagger:ignore
Count int `json:"count"` Count float64 `json:"count"`
// Total number of messages matching current query // Total number of messages matching current query
MessagesCount int `json:"messages_count"` MessagesCount float64 `json:"messages_count"`
// Pagination offset // Pagination offset
Start int `json:"start"` Start int `json:"start"`

View File

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

View File

@ -78,11 +78,10 @@ func Run() {
type message struct { type message struct {
ID string ID string
Size int Size float64
} }
func handleClient(conn net.Conn) { func handleClient(conn net.Conn) {
var ( var (
user = "" user = ""
state = 1 state = 1
@ -92,7 +91,7 @@ func handleClient(conn net.Conn) {
defer func() { defer func() {
if state == UPDATE { if state == UPDATE {
for _, id := range toDelete { for _, id := range toDelete {
_ = storage.DeleteOneMessage(id) _ = storage.DeleteMessages([]string{id})
} }
if len(toDelete) > 0 { if len(toDelete) > 0 {
// update web UI to remove deleted messages // update web UI to remove deleted messages
@ -178,19 +177,19 @@ func handleClient(conn net.Conn) {
} }
} else if cmd == "STAT" && state == TRANSACTION { } else if cmd == "STAT" && state == TRANSACTION {
totalSize := 0 totalSize := float64(0)
for _, m := range messages { for _, m := range messages {
totalSize = totalSize + m.Size totalSize = totalSize + m.Size
} }
sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), totalSize)) sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize)))
} else if cmd == "LIST" && state == TRANSACTION { } else if cmd == "LIST" && state == TRANSACTION {
totalSize := 0 totalSize := float64(0)
for _, m := range messages { for _, m := range messages {
totalSize = totalSize + m.Size totalSize = totalSize + m.Size
} }
sendData(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), totalSize)) sendData(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize)))
// print all sizes // print all sizes
for row, m := range messages { for row, m := range messages {
@ -200,7 +199,7 @@ func handleClient(conn net.Conn) {
sendData(conn, ".") sendData(conn, ".")
} else if cmd == "UIDL" && state == TRANSACTION { } else if cmd == "UIDL" && state == TRANSACTION {
totalSize := 0 totalSize := float64(0)
for _, m := range messages { for _, m := range messages {
totalSize = totalSize + m.Size totalSize = totalSize + m.Size
} }

View File

@ -193,20 +193,6 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
} }
} }
if auth.UICredentials != nil {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !auth.UICredentials.Match(user, pass) {
basicAuthResponse(w)
return
}
}
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
fn(w, r) fn(w, r)
return return

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"strings" "strings"
"testing" "testing"
@ -204,11 +205,15 @@ func TestAPIv1Search(t *testing.T) {
func setup() { func setup() {
logger.NoLogging = true logger.NoLogging = true
config.MaxMessages = 0 config.MaxMessages = 0
config.DataFile = "" config.Database = os.Getenv("MP_DATABASE")
if err := storage.InitDB(); err != nil { if err := storage.InitDB(); err != nil {
panic(err) panic(err)
} }
if err := storage.DeleteAllMessages(); err != nil {
panic(err)
}
} }
func assertStatsEqual(t *testing.T, uri string, unread, total int) { func assertStatsEqual(t *testing.T, uri string, unread, total int) {
@ -225,8 +230,8 @@ func assertStatsEqual(t *testing.T, uri string, unread, total int) {
return return
} }
assertEqual(t, unread, m.Unread, "wrong unread count") assertEqual(t, float64(unread), m.Unread, "wrong unread count")
assertEqual(t, total, m.Total, "wrong total count") assertEqual(t, float64(total), m.Total, "wrong total count")
} }
func assertSearchEqual(t *testing.T, uri, query string, count int) { func assertSearchEqual(t *testing.T, uri, query string, count int) {
@ -246,7 +251,7 @@ func assertSearchEqual(t *testing.T, uri, query string, count int) {
return return
} }
assertEqual(t, count, m.MessagesCount, "wrong search results count") assertEqual(t, float64(count), m.MessagesCount, "wrong search results count")
} }
func insertEmailData(t *testing.T) { func insertEmailData(t *testing.T) {

View File

@ -16,6 +16,7 @@ export default {
beforeMount() { beforeMount() {
document.title = document.title + ' - ' + location.hostname document.title = document.title + ' - ' + location.hostname
mailbox.showTagColors = !localStorage.getItem('hideTagColors') == '1' mailbox.showTagColors = !localStorage.getItem('hideTagColors') == '1'
mailbox.timeZone = localStorage.getItem('timezone') ? localStorage.getItem('timezone') : Intl.DateTimeFormat().resolvedOptions().timeZone;
// load global config // load global config
this.get(this.resolve('/api/v1/webui'), false, function (response) { this.get(this.resolve('/api/v1/webui'), false, function (response) {

View File

@ -1,4 +1,3 @@
<script> <script>
import Attachments from './Attachments.vue' import Attachments from './Attachments.vue'
import Headers from './Headers.vue' import Headers from './Headers.vue'
@ -32,6 +31,7 @@ export default {
srcURI: false, srcURI: false,
iframes: [], // for resizing iframes: [], // for resizing
canSaveTags: false, // prevent auto-saving tags on render canSaveTags: false, // prevent auto-saving tags on render
availableTags: [],
messageTags: [], messageTags: [],
loadHeaders: false, loadHeaders: false,
htmlScore: false, htmlScore: false,
@ -90,7 +90,7 @@ export default {
// manually refresh tags // manually refresh tags
self.get(self.resolve(`/api/v1/tags`), false, function (response) { self.get(self.resolve(`/api/v1/tags`), false, function (response) {
mailbox.tags = response.data self.availableTags = response.data
self.$nextTick(function () { self.$nextTick(function () {
Tags.init('select[multiple]') Tags.init('select[multiple]')
// delay tag change detection to allow Tags to load // delay tag change detection to allow Tags to load
@ -239,7 +239,8 @@ export default {
<th class="small">From</th> <th class="small">From</th>
<td class="privacy"> <td class="privacy">
<span v-if="message.From"> <span v-if="message.From">
<span v-if="message.From.Name" class="text-spaces">{{ message.From.Name + " " }}</span> <span v-if="message.From.Name" class="text-spaces">{{ message.From.Name + " "
}}</span>
<span v-if="message.From.Address" class="small"> <span v-if="message.From.Address" class="small">
&lt;<a :href="searchURI(message.From.Address)" class="text-body"> &lt;<a :href="searchURI(message.From.Address)" class="text-body">
{{ message.From.Address }} {{ message.From.Address }}
@ -341,7 +342,7 @@ export default {
data-separator="|,|"> data-separator="|,|">
<option value="">Type a tag...</option> <option value="">Type a tag...</option>
<!-- you need at least one option with the placeholder --> <!-- you need at least one option with the placeholder -->
<option v-for="t in mailbox.tags" :value="t">{{ t }}</option> <option v-for="t in availableTags" :value="t">{{ t }}</option>
</select> </select>
<div class="invalid-feedback">Invalid tag name</div> <div class="invalid-feedback">Invalid tag name</div>
</td> </td>
@ -423,7 +424,8 @@ export default {
Raw Raw
</button> </button>
<div class="dropdown d-xl-none"> <div class="dropdown d-xl-none">
<button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <button class="nav-link dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
Checks Checks
</button> </button>
<ul class="dropdown-menu checks"> <ul class="dropdown-menu checks">
@ -465,8 +467,9 @@ export default {
</ul> </ul>
</div> </div>
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab" <button class="d-none d-xl-inline-block nav-link position-relative" id="nav-html-check-tab"
data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html" data-bs-toggle="tab" data-bs-target="#nav-html-check" type="button" role="tab"
aria-selected="false" v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''"> aria-controls="nav-html" aria-selected="false"
v-if="!mailbox.uiConfig.DisableHTMLCheck && message.HTML != ''">
HTML Check HTML Check
<span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false"> <span class="badge rounded-pill p-1" :class="htmlScoreColor" v-if="htmlScore !== false">
<small>{{ Math.floor(htmlScore) }}%</small> <small>{{ Math.floor(htmlScore) }}%</small>
@ -482,8 +485,8 @@ export default {
</span> </span>
</button> </button>
<button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab" <button class="d-none d-xl-inline-block nav-link position-relative" id="nav-spam-check-tab"
data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab" aria-controls="nav-html" data-bs-toggle="tab" data-bs-target="#nav-spam-check" type="button" role="tab"
aria-selected="false" v-if="mailbox.uiConfig.SpamAssassin"> aria-controls="nav-html" aria-selected="false" v-if="mailbox.uiConfig.SpamAssassin">
Spam Analysis Spam Analysis
<span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false"> <span class="badge rounded-pill" :class="spamScoreColor" v-if="spamScore !== false">
<small>{{ spamScore }}</small> <small>{{ spamScore }}</small>
@ -505,22 +508,25 @@ export default {
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel" <div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
aria-labelledby="nav-html-tab" tabindex="0"> aria-labelledby="nav-html-tab" tabindex="0">
<div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]"> <div id="responsive-view" :class="scaleHTMLPreview" :style="responsiveSizes[scaleHTMLPreview]">
<iframe target-blank="" class="tab-pane d-block" id="preview-html" :srcdoc="sanitizeHTML(message.HTML)" <iframe target-blank="" class="tab-pane d-block" id="preview-html"
v-on:load="resizeIframe" frameborder="0" style="width: 100%; height: 100%; background: #fff;"> :srcdoc="sanitizeHTML(message.HTML)" v-on:load="resizeIframe" frameborder="0"
style="width: 100%; height: 100%; background: #fff;">
</iframe> </iframe>
</div> </div>
<Attachments v-if="allAttachments(message).length" :message="message" <Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments> :attachments="allAttachments(message)">
</Attachments>
</div> </div>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab" <div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
tabindex="0" v-if="message.HTML"> tabindex="0" v-if="message.HTML">
<pre><code class="language-html">{{ message.HTML }}</code></pre> <pre><code class="language-html">{{ message.HTML }}</code></pre>
</div> </div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab" tabindex="0" <div class="tab-pane fade" id="nav-plain-text" role="tabpanel" aria-labelledby="nav-plain-text-tab"
:class="message.HTML == '' ? 'show' : ''"> tabindex="0" :class="message.HTML == '' ? 'show' : ''">
<div class="text-view" v-html="textToHTML(message.Text)"></div> <div class="text-view" v-html="textToHTML(message.Text)"></div>
<Attachments v-if="allAttachments(message).length" :message="message" <Attachments v-if="allAttachments(message).length" :message="message"
:attachments="allAttachments(message)"></Attachments> :attachments="allAttachments(message)">
</Attachments>
</div> </div>
<div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0"> <div class="tab-pane fade" id="nav-headers" role="tabpanel" aria-labelledby="nav-headers-tab" tabindex="0">
<Headers v-if="loadHeaders" :message="message"></Headers> <Headers v-if="loadHeaders" :message="message"></Headers>
@ -536,8 +542,8 @@ export default {
</div> </div>
<div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab" <div class="tab-pane fade" id="nav-spam-check" role="tabpanel" aria-labelledby="nav-spam-check-tab"
tabindex="0"> tabindex="0">
<SpamAssassin v-if="mailbox.uiConfig.SpamAssassin" :message="message" @setSpamScore="(n) => spamScore = n" <SpamAssassin v-if="mailbox.uiConfig.SpamAssassin" :message="message"
@set-badge-style="(v) => spamScoreColor = v" /> @setSpamScore="(n) => spamScore = n" @set-badge-style="(v) => spamScoreColor = v" />
</div> </div>
<div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab" <div class="tab-pane fade" id="nav-link-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
tabindex="0"> tabindex="0">

View File

@ -19,6 +19,7 @@ export const mailbox = reactive({
appInfo: {}, // application information appInfo: {}, // application information
uiConfig: {}, // configuration for UI uiConfig: {}, // configuration for UI
lastMessage: false, // return scrolling lastMessage: false, // return scrolling
timeZone: '', // browser timezone
}) })
watch( watch(

View File

@ -59,6 +59,9 @@ export default {
} }
this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s) this.apiURI = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
if (mailbox.timeZone != '' && (s.indexOf('after:') != -1 || s.indexOf('before:') != -1)) {
this.apiURI += '&tz=' + encodeURIComponent(mailbox.timeZone)
}
this.loadMessages() this.loadMessages()
} }
} }

View File

@ -550,6 +550,12 @@
"description": "Limit results", "description": "Limit results",
"name": "limit", "name": "limit",
"in": "query" "in": "query"
},
{
"type": "string",
"description": "Timezone for `before:` \u0026 `after:` queries, eg: \"Pacific/Auckland\"",
"name": "tz",
"in": "query"
} }
], ],
"responses": { "responses": {
@ -773,8 +779,8 @@
}, },
"DatabaseSize": { "DatabaseSize": {
"description": "Database size in bytes", "description": "Database size in bytes",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"LatestVersion": { "LatestVersion": {
"description": "Latest Mailpit version", "description": "Latest Mailpit version",
@ -782,8 +788,8 @@
}, },
"Messages": { "Messages": {
"description": "Total number of messages in the database", "description": "Total number of messages in the database",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"RuntimeStats": { "RuntimeStats": {
"description": "Runtime statistics", "description": "Runtime statistics",
@ -796,33 +802,33 @@
}, },
"MessagesDeleted": { "MessagesDeleted": {
"description": "Database runtime messages deleted", "description": "Database runtime messages deleted",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"SMTPAccepted": { "SMTPAccepted": {
"description": "Accepted runtime SMTP messages", "description": "Accepted runtime SMTP messages",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"SMTPAcceptedSize": { "SMTPAcceptedSize": {
"description": "Total runtime accepted messages size in bytes", "description": "Total runtime accepted messages size in bytes",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"SMTPIgnored": { "SMTPIgnored": {
"description": "Ignored runtime SMTP messages (when using --ignore-duplicate-ids)", "description": "Ignored runtime SMTP messages (when using --ignore-duplicate-ids)",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"SMTPRejected": { "SMTPRejected": {
"description": "Rejected runtime SMTP messages", "description": "Rejected runtime SMTP messages",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"Uptime": { "Uptime": {
"description": "Mailpit server uptime in seconds", "description": "Mailpit server uptime in seconds",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
} }
} }
}, },
@ -836,8 +842,8 @@
}, },
"Unread": { "Unread": {
"description": "Total number of messages in the database", "description": "Total number of messages in the database",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"Version": { "Version": {
"description": "Current Mailpit version", "description": "Current Mailpit version",
@ -868,8 +874,8 @@
}, },
"Size": { "Size": {
"description": "Size in bytes", "description": "Size in bytes",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
} }
}, },
"x-go-package": "github.com/axllent/mailpit/internal/storage" "x-go-package": "github.com/axllent/mailpit/internal/storage"
@ -1176,8 +1182,8 @@
}, },
"Size": { "Size": {
"description": "Message size in bytes", "description": "Message size in bytes",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"Subject": { "Subject": {
"description": "Message subject", "description": "Message subject",
@ -1268,8 +1274,8 @@
}, },
"Size": { "Size": {
"description": "Message size in bytes (total)", "description": "Message size in bytes (total)",
"type": "integer", "type": "number",
"format": "int64" "format": "double"
}, },
"Snippet": { "Snippet": {
"description": "Message snippet includes up to 250 characters", "description": "Message snippet includes up to 250 characters",
@ -1310,8 +1316,8 @@
}, },
"messages_count": { "messages_count": {
"description": "Total number of messages matching current query", "description": "Total number of messages matching current query",
"type": "integer", "type": "number",
"format": "int64", "format": "double",
"x-go-name": "MessagesCount" "x-go-name": "MessagesCount"
}, },
"start": { "start": {
@ -1330,14 +1336,14 @@
}, },
"total": { "total": {
"description": "Total number of messages in mailbox", "description": "Total number of messages in mailbox",
"type": "integer", "type": "number",
"format": "int64", "format": "double",
"x-go-name": "Total" "x-go-name": "Total"
}, },
"unread": { "unread": {
"description": "Total number of unread messages in mailbox", "description": "Total number of unread messages in mailbox",
"type": "integer", "type": "number",
"format": "int64", "format": "double",
"x-go-name": "Unread" "x-go-name": "Unread"
} }
}, },