mirror of
https://github.com/axllent/mailpit.git
synced 2024-12-26 22:56:43 +02:00
Feature: HTML check to test & score mail client compatibility with HTML emails
This commit is contained in:
parent
7d2716ee17
commit
1922651d41
@ -20,6 +20,7 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but modern and much, muc
|
||||
- Runs entirely from a single binary, no installation required
|
||||
- SMTP server (default `0.0.0.0:1025`)
|
||||
- Web UI to view emails (formatted HTML, highlighted HTML source, text, headers, raw source and MIME attachments including image thumbnails)
|
||||
- HTML check to test & score mail client compatibility with HTML emails
|
||||
- Light & dark web UI theme with auto-detect
|
||||
- Mobile and tablet HTML preview toggle in desktop mode
|
||||
- Advanced mail search ([see wiki](https://github.com/axllent/mailpit/wiki/Mail-search))
|
||||
|
@ -88,6 +88,7 @@ func init() {
|
||||
rootCmd.Flags().StringVar(&server.AccessControlAllowOrigin, "api-cors", server.AccessControlAllowOrigin, "Set API CORS Access-Control-Allow-Origin header")
|
||||
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.DisableHTMLCheck, "disable-html-check", config.DisableHTMLCheck, "Disable the HTML check functionality (web UI & API)")
|
||||
rootCmd.Flags().BoolVar(&config.BlockRemoteCSSAndFonts, "block-remote-css-and-fonts", config.BlockRemoteCSSAndFonts, "Block access to remote CSS & fonts")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
|
||||
@ -206,6 +207,9 @@ func initConfigFromEnv() {
|
||||
if getEnabledFromEnv("MP_IGNORE_DUPLICATE_IDS") {
|
||||
config.IgnoreDuplicateIDs = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_DISABLE_HTML_CHECK") {
|
||||
config.DisableHTMLCheck = true
|
||||
}
|
||||
if getEnabledFromEnv("MP_BLOCK_REMOTE_CSS_AND_FONTS") {
|
||||
config.BlockRemoteCSSAndFonts = true
|
||||
}
|
||||
|
@ -69,6 +69,9 @@ var (
|
||||
// IgnoreDuplicateIDs will skip messages with the same ID
|
||||
IgnoreDuplicateIDs bool
|
||||
|
||||
// DisableHTMLCheck used to disable the HTML check in bother the API and web UI
|
||||
DisableHTMLCheck = false
|
||||
|
||||
// BlockRemoteCSSAndFonts used to disable remote CSS & fonts
|
||||
BlockRemoteCSSAndFonts = false
|
||||
|
||||
|
6
go.mod
6
go.mod
@ -28,11 +28,15 @@ require (
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // 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/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/gomarkdown/markdown v0.0.0-20230711084535-11b03c0ae6d6 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
@ -46,6 +50,8 @@ require (
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
github.com/vanng822/go-premailer v1.20.2 // indirect
|
||||
golang.org/x/crypto v0.11.0 // indirect
|
||||
golang.org/x/image v0.9.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
|
36
go.sum
36
go.sum
@ -4,6 +4,14 @@ github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcv
|
||||
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.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
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/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
|
||||
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=
|
||||
@ -39,16 +47,21 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
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/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230711084535-11b03c0ae6d6 h1:MJj8EdNUiK9LvcNc/sdrJqE6lB+nGGJ5qUg/ycfAwqs=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230711084535-11b03c0ae6d6/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
@ -122,6 +135,7 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@ -129,10 +143,17 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/tg123/go-htpasswd v1.2.1 h1:i4wfsX1KvvkyoMiHZzjS0VzbAPWfxzI8INcZAKtutoU=
|
||||
github.com/tg123/go-htpasswd v1.2.1/go.mod h1:erHp1B86KXdwQf1X5ZrLb7erXZnWueEQezb2dql4q58=
|
||||
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.20.2 h1:vKs4VdtfXDqL7IXC2pkiBObc1bXM9bYH3Wa+wYw2DnI=
|
||||
github.com/vanng822/go-premailer v1.20.2/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
@ -143,11 +164,18 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -155,22 +183,29 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@ -181,6 +216,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8=
|
||||
golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
|
11
package-lock.json
generated
11
package-lock.json
generated
@ -17,7 +17,8 @@
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
"tinycon": "^0.6.8",
|
||||
"vue": "^3.2.13"
|
||||
"vue": "^3.2.13",
|
||||
"vue-css-donut-chart": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popperjs/core": "^2.11.5",
|
||||
@ -2398,6 +2399,14 @@
|
||||
"@vue/shared": "3.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-css-donut-chart": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-css-donut-chart/-/vue-css-donut-chart-2.0.0.tgz",
|
||||
"integrity": "sha512-rT7Ytk2IYBLS3hfWSiTWaY+kVS649+ZwAQofl1Xq1wOhH5FgmcjT0a/whu67bVQ59aTVGX45MGvGppweu+u3Cw==",
|
||||
"peerDependencies": {
|
||||
"vue": "^3"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "4.0.0-beta.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||
|
@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs",
|
||||
"watch": "WATCH=true node esbuild.config.mjs",
|
||||
"package": "MINIFY=true node esbuild.config.mjs"
|
||||
"package": "MINIFY=true node esbuild.config.mjs",
|
||||
"update-caniemail": "wget -O utils/html-check/caniemail-data.json https://www.caniemail.com/api/data.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.2.1",
|
||||
@ -17,7 +18,8 @@
|
||||
"prismjs": "^1.29.0",
|
||||
"rapidoc": "^9.3.4",
|
||||
"tinycon": "^0.6.8",
|
||||
"vue": "^3.2.13"
|
||||
"vue": "^3.2.13",
|
||||
"vue-css-donut-chart": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popperjs/core": "^2.11.5",
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/server/smtpd"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/htmlcheck"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/axllent/mailpit/utils/tools"
|
||||
"github.com/gorilla/mux"
|
||||
@ -624,6 +625,58 @@ func ReleaseMessage(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// HTMLCheck returns a summary of the HTML client support
|
||||
func HTMLCheck(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheckResponse
|
||||
//
|
||||
// # HTML check (beta)
|
||||
//
|
||||
// Returns the summary of HTML check.
|
||||
//
|
||||
// NOTE: This feature is currently in beta and is documented for reference only.
|
||||
// Please do not integrate with it (yet) as there may be changes.
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Parameters:
|
||||
// + name: ID
|
||||
// in: path
|
||||
// description: Database ID
|
||||
// required: true
|
||||
// type: string
|
||||
//
|
||||
// Responses:
|
||||
// 200: HTMLCheckResponse
|
||||
// default: ErrorResponse
|
||||
|
||||
vars := mux.Vars(r)
|
||||
id := vars["id"]
|
||||
|
||||
msg, err := storage.GetMessage(id)
|
||||
if err != nil {
|
||||
fourOFour(w)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.HTML == "" {
|
||||
httpError(w, "message does not contain HTML")
|
||||
return
|
||||
}
|
||||
|
||||
checks, err := htmlcheck.RunTests(msg.HTML)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(checks)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// FourOFour returns a basic 404 message
|
||||
func fourOFour(w http.ResponseWriter) {
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
|
@ -2,6 +2,7 @@ package apiv1
|
||||
|
||||
import (
|
||||
"github.com/axllent/mailpit/storage"
|
||||
"github.com/axllent/mailpit/utils/htmlcheck"
|
||||
)
|
||||
|
||||
// MessagesSummary is a summary of a list of messages
|
||||
@ -45,3 +46,6 @@ type Message = storage.Message
|
||||
|
||||
// Attachment summary
|
||||
type Attachment = storage.Attachment
|
||||
|
||||
// HTMLCheckResponse summary
|
||||
type HTMLCheckResponse = htmlcheck.Response
|
||||
|
@ -23,10 +23,13 @@ type webUIConfiguration struct {
|
||||
// Allowlist of accepted recipients
|
||||
RecipientAllowlist string
|
||||
}
|
||||
|
||||
// Whether the HTML check has been globally disabled
|
||||
DisableHTMLCheck bool
|
||||
}
|
||||
|
||||
// WebUIConfig returns configuration settings for the web UI.
|
||||
func WebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
// swagger:route GET /api/v1/webui application WebUIConfiguration
|
||||
//
|
||||
// # Get web UI configuration
|
||||
@ -51,6 +54,8 @@ func WebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.RecipientAllowlist
|
||||
}
|
||||
|
||||
conf.DisableHTMLCheck = config.DisableHTMLCheck
|
||||
|
||||
bytes, _ := json.Marshal(conf)
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
|
@ -90,6 +90,9 @@ func defaultRoutes() *mux.Router {
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/headers", middleWareFunc(apiv1.GetHeaders)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/raw", middleWareFunc(apiv1.DownloadRaw)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/release", middleWareFunc(apiv1.ReleaseMessage)).Methods("POST")
|
||||
if !config.DisableHTMLCheck {
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}/html-check", middleWareFunc(apiv1.HTMLCheck)).Methods("GET")
|
||||
}
|
||||
r.HandleFunc(config.Webroot+"api/v1/message/{id}", middleWareFunc(apiv1.GetMessage)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/info", middleWareFunc(apiv1.AppInfo)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/webui", middleWareFunc(apiv1.WebUIConfig)).Methods("GET")
|
||||
|
@ -44,7 +44,7 @@ export default {
|
||||
tcStatus: 0,
|
||||
appInfo: false,
|
||||
lastLoaded: false,
|
||||
relayConfig: {},
|
||||
uiConfig: {},
|
||||
releaseAddresses: false,
|
||||
toastMessage: false,
|
||||
}
|
||||
@ -191,7 +191,7 @@ export default {
|
||||
getUISettings: function () {
|
||||
let self = this
|
||||
self.get('api/v1/webui', null, function (response) {
|
||||
self.relayConfig = response.data
|
||||
self.uiConfig = response.data
|
||||
})
|
||||
},
|
||||
|
||||
@ -716,7 +716,7 @@ export default {
|
||||
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-2" title="Release message"
|
||||
v-if="relayConfig.MessageRelay && relayConfig.MessageRelay.Enabled" v-on:click="initReleaseModal">
|
||||
v-if="uiConfig.MessageRelay && uiConfig.MessageRelay.Enabled" v-on:click="initReleaseModal">
|
||||
<i class="bi bi-send"></i> <span class="d-none d-md-inline">Release</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-light me-2" title="Delete message" v-on:click="deleteMessages">
|
||||
@ -1006,7 +1006,8 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="message" :message="message" :existingTags="existingTags" @load-messages="loadMessages">
|
||||
<Message v-if="message" :message="message" :existingTags="existingTags" :uiConfig="uiConfig"
|
||||
@load-messages="loadMessages">
|
||||
</Message>
|
||||
</div>
|
||||
<div id="loading" v-if="loading">
|
||||
@ -1159,13 +1160,11 @@ export default {
|
||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit" target="_blank">
|
||||
<i class="bi bi-github"></i>
|
||||
Github
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<a class="btn btn-primary w-100" href="https://github.com/axllent/mailpit/wiki" target="_blank">
|
||||
Documentation
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@ -1195,7 +1194,7 @@ export default {
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="ReleaseModal" tabindex="-1" aria-labelledby="AppInfoModalLabel" aria-hidden="true">
|
||||
<MessageRelease v-if="releaseAddresses" :message="message" :relayConfig="relayConfig"
|
||||
<MessageRelease v-if="releaseAddresses" :message="message" :uiConfig="uiConfig"
|
||||
:releaseAddresses="releaseAddresses"></MessageRelease>
|
||||
</div>
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "bootstrap";
|
||||
@import "./bootstrap";
|
||||
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
@ -46,6 +46,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
:not(.text-view) > a {
|
||||
&[href^="http://"],
|
||||
&[href^="https://"]
|
||||
{
|
||||
&:after {
|
||||
content: "\f1c5";
|
||||
display: inline-block;
|
||||
font-family: "bootstrap-icons" !important;
|
||||
font-style: normal;
|
||||
font-weight: normal !important;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
vertical-align: -0.125em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -5,16 +5,19 @@ import Prism from "prismjs"
|
||||
import Tags from "bootstrap5-tags"
|
||||
import Attachments from './Attachments.vue'
|
||||
import Headers from './Headers.vue'
|
||||
import HTMLCheck from './MessageHTMLCheck.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
existingTags: Array
|
||||
existingTags: Array,
|
||||
uiConfig: Object
|
||||
},
|
||||
|
||||
components: {
|
||||
Attachments,
|
||||
Headers,
|
||||
HTMLCheck,
|
||||
},
|
||||
|
||||
mixins: [commonMixins],
|
||||
@ -28,6 +31,8 @@ export default {
|
||||
messageTags: [],
|
||||
allTags: [],
|
||||
loadHeaders: false,
|
||||
htmlScore: false,
|
||||
htmlScoreColor: false,
|
||||
showMobileButtons: false,
|
||||
scaleHTMLPreview: 'display',
|
||||
// keys names match bootstrap icon names
|
||||
@ -75,7 +80,7 @@ export default {
|
||||
if (this.scaleHTMLPreview == 'display') {
|
||||
let self = this
|
||||
window.setTimeout(function () {
|
||||
self.resizeIframes()
|
||||
self.resizeIFrames()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
@ -86,8 +91,7 @@ export default {
|
||||
self.showTags = false
|
||||
self.canSaveTags = false
|
||||
self.allTags = self.existingTags
|
||||
window.addEventListener("resize", self.resizeIframes)
|
||||
self.renderUI()
|
||||
window.addEventListener("resize", self.resizeIFrames)
|
||||
|
||||
let headersTab = document.getElementById('nav-headers-tab')
|
||||
headersTab.addEventListener('shown.bs.tab', function (event) {
|
||||
@ -97,7 +101,7 @@ export default {
|
||||
let rawTab = document.getElementById('nav-raw-tab')
|
||||
rawTab.addEventListener('shown.bs.tab', function (event) {
|
||||
self.srcURI = 'api/v1/message/' + self.message.ID + '/raw'
|
||||
self.resizeIframes()
|
||||
self.resizeIFrames()
|
||||
})
|
||||
|
||||
self.showTags = true
|
||||
@ -112,7 +116,7 @@ export default {
|
||||
},
|
||||
|
||||
unmounted: function () {
|
||||
window.removeEventListener("resize", this.resizeIframes)
|
||||
window.removeEventListener("resize", this.resizeIFrames)
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -122,6 +126,7 @@ export default {
|
||||
},
|
||||
renderUI: function () {
|
||||
let self = this
|
||||
|
||||
// click the first non-disabled tab
|
||||
document.querySelector('#nav-tab button:not([disabled])').click()
|
||||
document.activeElement.blur() // blur focus
|
||||
@ -149,7 +154,7 @@ export default {
|
||||
anchorEl.setAttribute('target', '_blank')
|
||||
}
|
||||
}
|
||||
self.resizeIframes()
|
||||
self.resizeIFrames()
|
||||
}
|
||||
}, 200)
|
||||
|
||||
@ -164,7 +169,7 @@ export default {
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
|
||||
},
|
||||
|
||||
resizeIframes: function () {
|
||||
resizeIFrames: function () {
|
||||
if (this.scaleHTMLPreview != 'display') {
|
||||
return
|
||||
}
|
||||
@ -233,7 +238,7 @@ export default {
|
||||
.replace(/ˠˠˠ/g, '"')
|
||||
|
||||
return html
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -341,7 +346,9 @@ export default {
|
||||
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
|
||||
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab" data-bs-target="#nav-html" type="button"
|
||||
role="tab" aria-controls="nav-html" aria-selected="true" v-if="message.HTML" ref="navhtml"
|
||||
v-on:click="resizeIframes()">HTML</button>
|
||||
v-on:click="resizeIFrames()">
|
||||
HTML
|
||||
</button>
|
||||
<button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab" data-bs-target="#nav-html-source"
|
||||
type="button" role="tab" aria-controls="nav-html-source" aria-selected="false" v-if="message.HTML">
|
||||
HTML <span class="d-sm-none">Src</span><span class="d-none d-sm-inline">Source</span>
|
||||
@ -359,8 +366,17 @@ export default {
|
||||
role="tab" aria-controls="nav-raw" aria-selected="false">
|
||||
Raw
|
||||
</button>
|
||||
<button class="nav-link position-relative" id="nav-html-check-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#nav-html-check" type="button" role="tab" aria-controls="nav-html" aria-selected="false"
|
||||
v-if="!uiConfig.DisableHTMLCheck && message.HTML != ''" @click="showMobileButtons = false">
|
||||
<span class="d-none d-sm-inline">HTML</span> Check
|
||||
<span class="position-absolute top-10 start-100 translate-middle badge rounded-pill p-1"
|
||||
:class="htmlScoreColor" v-if="htmlScore !== false">
|
||||
<small>{{ Math.floor(htmlScore) }}%</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="d-none d-lg-block ms-auto me-2" v-if="showMobileButtons">
|
||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
||||
<template v-for="vals, key in responsiveSizes">
|
||||
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
||||
v-on:click="scaleHTMLPreview = key">
|
||||
@ -382,6 +398,11 @@ export default {
|
||||
<Attachments v-if="allAttachments(message).length" :message="message"
|
||||
:attachments="allAttachments(message)"></Attachments>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-check" role="tabpanel" aria-labelledby="nav-html-check-tab"
|
||||
tabindex="0">
|
||||
<HTMLCheck v-if="!uiConfig.DisableHTMLCheck && message.HTML != ''" :message="message"
|
||||
@setHtmlScore="(n) => htmlScore = n" @set-badge-style="(v) => htmlScoreColor = v" />
|
||||
</div>
|
||||
<div class="tab-pane fade" id="nav-html-source" role="tabpanel" aria-labelledby="nav-html-source-tab"
|
||||
tabindex="0" v-if="message.HTML">
|
||||
<pre><code class="language-html">{{ message.HTML }}</code></pre>
|
||||
|
670
server/ui-src/templates/MessageHTMLCheck.vue
Normal file
670
server/ui-src/templates/MessageHTMLCheck.vue
Normal file
@ -0,0 +1,670 @@
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import Donut from 'vue-css-donut-chart/src/components/Donut.vue'
|
||||
import commonMixins from '../mixins.js'
|
||||
import { Tooltip } from 'bootstrap'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
|
||||
components: {
|
||||
Donut,
|
||||
},
|
||||
|
||||
emits: ["setHtmlScore", "setBadgeStyle"],
|
||||
|
||||
mixins: [commonMixins],
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: false,
|
||||
enabled: true,
|
||||
check: false,
|
||||
platforms: [],
|
||||
allPlatforms: {
|
||||
"windows": "Windows",
|
||||
"windows-mail": "Windows Mail",
|
||||
"outlook-com": "Outlook.com",
|
||||
"macos": "macOS",
|
||||
"ios": "iOS",
|
||||
"android": "Android",
|
||||
"desktop-webmail": "Desktop Webmail",
|
||||
"mobile-webmail": "Mobile Webmail",
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.enabled = !localStorage.getItem('htmlCheckDisabled')
|
||||
this.loadConfig()
|
||||
this.doCheck()
|
||||
},
|
||||
|
||||
computed: {
|
||||
summary: function () {
|
||||
let self = this
|
||||
|
||||
if (!this.enabled || !this.check) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = {
|
||||
Warnings: [],
|
||||
Total: {
|
||||
Nodes: this.check.Total.Nodes
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.check.Warnings.length; i++) {
|
||||
let o = JSON.parse(JSON.stringify(this.check.Warnings[i]))
|
||||
|
||||
// for <script> test
|
||||
if (o.Results.length == 0) {
|
||||
result.Warnings.push(o)
|
||||
continue
|
||||
}
|
||||
|
||||
// filter by enabled platforms
|
||||
let results = o.Results.filter(function (w) {
|
||||
return self.platforms.indexOf(w.Platform) != -1
|
||||
})
|
||||
|
||||
if (results.length == 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// recalculate the percentages
|
||||
let y = 0, p = 0, n = 0
|
||||
|
||||
results.forEach(function (r) {
|
||||
if (r.Support == "yes") {
|
||||
y++
|
||||
} else if (r.Support == "partial") {
|
||||
p++
|
||||
} else {
|
||||
n++
|
||||
}
|
||||
})
|
||||
let total = y + p + n
|
||||
o.Results = results
|
||||
o.Score = {
|
||||
Found: o.Score.Found,
|
||||
Supported: y / total * 100,
|
||||
Partial: p / total * 100,
|
||||
Unsupported: n / total * 100
|
||||
}
|
||||
|
||||
result.Warnings.push(o)
|
||||
}
|
||||
|
||||
let maxPartial = 0, maxUnsupported = 0
|
||||
result.Warnings.forEach(function (w) {
|
||||
let scoreWeight = 1
|
||||
if (w.Score.Found < result.Total.Nodes) {
|
||||
// each error is weighted based on the number of occurrences vs: the total message nodes
|
||||
scoreWeight = w.Score.Found / result.Total.Nodes
|
||||
}
|
||||
|
||||
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
|
||||
// are actually used in the HTML, and including things like bootstrap styles completely throws
|
||||
// off the calculation as these dominate.
|
||||
if (self.isPseudoClassOrAtRule(w.Title)) {
|
||||
scoreWeight = 0.05
|
||||
w.PseudoClassOrAtRule = true
|
||||
}
|
||||
|
||||
let scorePartial = w.Score.Partial * scoreWeight
|
||||
let scoreUnsupported = w.Score.Unsupported * scoreWeight
|
||||
if (scorePartial > maxPartial) {
|
||||
maxPartial = scorePartial
|
||||
}
|
||||
if (scoreUnsupported > maxUnsupported) {
|
||||
maxUnsupported = scoreUnsupported
|
||||
}
|
||||
})
|
||||
|
||||
// sort warnings by final score
|
||||
result.Warnings.sort(function (a, b) {
|
||||
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
|
||||
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
|
||||
|
||||
if (self.isPseudoClassOrAtRule(a.Title)) {
|
||||
aWeight = 0.05
|
||||
}
|
||||
|
||||
if (self.isPseudoClassOrAtRule(b.Title)) {
|
||||
bWeight = 0.05
|
||||
}
|
||||
|
||||
return (a.Score.Unsupported + a.Score.Partial) * aWeight < (b.Score.Unsupported + b.Score.Partial) * bWeight
|
||||
})
|
||||
|
||||
result.Total.Supported = 100 - maxPartial - maxUnsupported
|
||||
result.Total.Partial = maxPartial
|
||||
result.Total.Unsupported = maxUnsupported
|
||||
|
||||
this.$emit('setHtmlScore', result.Total.Supported)
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
graphSections: function () {
|
||||
let s = Math.round(this.summary.Total.Supported)
|
||||
let p = Math.round(this.summary.Total.Partial)
|
||||
let u = 100 - s - p
|
||||
return [
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Supported) + '% supported',
|
||||
value: s,
|
||||
color: '#198754'
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Partial) + '% partially supported',
|
||||
value: p,
|
||||
color: '#ffc107'
|
||||
},
|
||||
{
|
||||
label: this.round2dm(this.summary.Total.Unsupported) + '% not supported',
|
||||
value: u,
|
||||
color: '#dc3545'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// colors depend on both varying unsupported & partially unsupported percentages
|
||||
scoreColor: function () {
|
||||
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
|
||||
this.$emit('setBadgeStyle', 'bg-success')
|
||||
return 'text-success'
|
||||
} else if (this.summary.Total.Unsupported < 10 && this.summary.Total.Partial < 15) {
|
||||
this.$emit('setBadgeStyle', 'bg-warning text-primary')
|
||||
return 'text-warning'
|
||||
}
|
||||
|
||||
this.$emit('setBadgeStyle', 'bg-danger')
|
||||
return 'text-danger'
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
message: {
|
||||
handler() {
|
||||
this.$emit('setHtmlScore', false)
|
||||
this.doCheck()
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
platforms(v) {
|
||||
localStorage.setItem('html-check-platforms', JSON.stringify(v))
|
||||
},
|
||||
enabled(v) {
|
||||
if (!v) {
|
||||
localStorage.setItem('htmlCheckDisabled', true)
|
||||
this.$emit('setHtmlScore', false)
|
||||
} else {
|
||||
localStorage.removeItem('htmlCheckDisabled')
|
||||
this.doCheck()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
doCheck: function () {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.check = false
|
||||
|
||||
if (this.message.HTML == "") {
|
||||
return
|
||||
}
|
||||
|
||||
let self = this
|
||||
|
||||
// ignore any error, do not show loader
|
||||
axios.get('api/v1/message/' + self.message.ID + '/html-check', null)
|
||||
.then(function (result) {
|
||||
self.check = result.data
|
||||
self.error = false
|
||||
|
||||
// set tooltips
|
||||
window.setTimeout(function () {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
|
||||
}, 500)
|
||||
})
|
||||
.catch(function (error) {
|
||||
// handle error
|
||||
if (error.response && error.response.data) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (error.response.data.Error) {
|
||||
self.error = error.response.data.Error
|
||||
} else {
|
||||
self.error = error.response.data
|
||||
}
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
|
||||
// http.ClientRequest in node.js
|
||||
self.error = 'Error sending data to the server. Please try again.'
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
self.error = error.message
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
loadConfig: function () {
|
||||
let platforms = localStorage.getItem('html-check-platforms')
|
||||
if (platforms) {
|
||||
try {
|
||||
this.platforms = JSON.parse(platforms)
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
// set all options
|
||||
if (this.platforms.length == 0) {
|
||||
this.platforms = Object.keys(this.allPlatforms)
|
||||
}
|
||||
},
|
||||
|
||||
// return a platform's families (email clients)
|
||||
families: function (k) {
|
||||
if (this.check.Platforms[k]) {
|
||||
return this.check.Platforms[k]
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
|
||||
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
|
||||
isPseudoClassOrAtRule: function (t) {
|
||||
return t.match(/^(:|@)/)
|
||||
},
|
||||
|
||||
round: function (v) {
|
||||
return Math.round(v)
|
||||
},
|
||||
|
||||
round2dm: function (v) {
|
||||
return Math.round(v * 100) / 100
|
||||
},
|
||||
|
||||
scrollToWarnings: function () {
|
||||
if (!this.$refs.warnings) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$refs.warnings.scrollIntoView({ behavior: "smooth" })
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="error">
|
||||
<p>HTML check failed to load:</p>
|
||||
<div class="alert alert-warning">
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="!enabled">
|
||||
<h2 class="h4 text-secondary">HTML check is currently disabled</h2>
|
||||
<p class="text-secondary">
|
||||
This feature is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled" id="inlineEnableHTMLCheck">
|
||||
<label class="form-check-label" for="inlineEnableHTMLCheck">
|
||||
Enable HTML check
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="summary">
|
||||
<div class="mt-5 mb-3">
|
||||
<div class="row w-100">
|
||||
<div class="col-md-8">
|
||||
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px" :thickness="20"
|
||||
has-legend legend-placement="bottom" :total="100" :start-angle="0" :auto-adjust-text-size="true"
|
||||
@section-click="scrollToWarnings">
|
||||
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
|
||||
{{ round2dm(summary.Total.Supported) }}%
|
||||
</h2>
|
||||
<div class="text-body">
|
||||
support
|
||||
</div>
|
||||
<template #legend>
|
||||
<p class="my-3 small mb-1 text-center" @click="scrollToWarnings">
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-success"></i>
|
||||
{{ round2dm(summary.Total.Supported) }}% supported
|
||||
</span>
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-warning"></i>
|
||||
{{ round2dm(summary.Total.Partial) }}% partially supported
|
||||
</span>
|
||||
<span class="text-nowrap">
|
||||
<i class="bi bi-circle-fill text-danger"></i>
|
||||
{{ round2dm(summary.Total.Unsupported) }}% not supported
|
||||
</span>
|
||||
</p>
|
||||
<p class="small text-secondary">
|
||||
calculated from {{ formatNumber(check.Total.Tests) }} tests
|
||||
</p>
|
||||
</template>
|
||||
</Donut>
|
||||
|
||||
<div class="input-group justify-content-center mb-3">
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal"
|
||||
data-bs-target="#AboutHTMLCheckResults">
|
||||
<i class="bi bi-info-circle-fill"></i>
|
||||
Help
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#HTMLCheckOptions">
|
||||
<i class="bi bi-gear-fill"></i>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<h2 class="h5 mb-3">Tested platforms:</h2>
|
||||
<div class="form-check form-switch" v-for="p, k in allPlatforms">
|
||||
<input class="form-check-input" type="checkbox" role="switch" :value="k" v-model="platforms"
|
||||
:aria-label="p" :id="'Check_' + k">
|
||||
<label class="form-check-label" :for="'Check_' + k"
|
||||
:class="platforms.indexOf(k) !== -1 ? '' : 'text-secondary'" :title="families(k).join(', ')"
|
||||
data-bs-toggle="tooltip" :data-bs-title="families(k).join(', ')">
|
||||
{{ p }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="summary.Warnings.length">
|
||||
<h4 ref="warnings" class="h5 mt-4">
|
||||
{{ summary.Warnings.length }} Warnings from {{ formatNumber(summary.Total.Nodes) }} HTML nodes:
|
||||
</h4>
|
||||
<div class="accordion" id="warnings">
|
||||
<div class="accordion-item" v-for="warning in summary.Warnings">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
:data-bs-target="'#' + warning.Slug" aria-expanded="false" :aria-controls="warning.Slug">
|
||||
<div class="row w-100 w-lg-75">
|
||||
<div class="col-sm">
|
||||
{{ warning.Title }}
|
||||
<span class="ms-2 small badge text-bg-secondary" title="Test category">
|
||||
{{ warning.Category }}
|
||||
</span>
|
||||
<span class="ms-2 small badge text-bg-light"
|
||||
title="The number of times this was detected">
|
||||
x {{ warning.Score.Found }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm mt-2 mt-sm-0">
|
||||
<div class="progress-stacked">
|
||||
<div class="progress" role="progressbar" aria-label="Supported"
|
||||
:aria-valuenow="warning.Score.Supported" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Supported + '%' }" title="Supported">
|
||||
<div class="progress-bar bg-success">
|
||||
{{ round(warning.Score.Supported) + '%' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="Partial"
|
||||
:aria-valuenow="warning.Score.Partial" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Partial + '%' }" title="Partial support">
|
||||
<div class="progress-bar progress-bar-striped bg-warning text-dark">
|
||||
{{ round(warning.Score.Partial) + '%' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" role="progressbar" aria-label="No"
|
||||
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0" aria-valuemax="100"
|
||||
:style="{ width: warning.Score.Unsupported + '%' }" title="Not supported">
|
||||
<div class="progress-bar bg-danger">
|
||||
{{ round(warning.Score.Unsupported) + '%' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</h2>
|
||||
<div :id="warning.Slug" class="accordion-collapse collapse" data-bs-parent="#warnings">
|
||||
<div class="accordion-body">
|
||||
<p v-if="warning.Description != '' || warning.PseudoClassOrAtRule">
|
||||
<span v-if="warning.PseudoClassOrAtRule" class="d-block alert alert-warning mb-2">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
|
||||
propert<template v-if="warning.Score.Found === 1">y</template><template
|
||||
v-else>ies</template> in the CSS styles, but unable to test if used or not.
|
||||
</span>
|
||||
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
|
||||
</p>
|
||||
|
||||
<template v-if="warning.Results.length">
|
||||
<h3 class="h6">Clients with partial or no support:</h3>
|
||||
<p>
|
||||
<small v-for="warning in warning.Results" class="text-nowrap d-inline-block me-4">
|
||||
<i class="bi bi-circle-fill"
|
||||
:class="warning.Support == 'no' ? 'text-danger' : 'text-warning'"
|
||||
:title="warning.Support == 'no' ? 'Not supported' : 'Partially supported'"></i>
|
||||
{{ warning.Name }}
|
||||
<span class="badge text-bg-secondary" v-if="warning.NoteNumber != ''"
|
||||
title="See notes">
|
||||
{{ warning.NoteNumber }}
|
||||
</span>
|
||||
</small>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(warning.NotesByNumber).length" class="mt-3">
|
||||
<h3 class="h6">Notes:</h3>
|
||||
<div v-for="n, i in warning.NotesByNumber" class="small row my-2">
|
||||
<div class="col-auto pe-0">
|
||||
<span class="badge text-bg-secondary">
|
||||
{{ i }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col" v-html="n"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small mt-3 mb-0" v-if="warning.URL">
|
||||
<a :href="warning.URL" target="_blank">Online reference</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-secondary small mt-4">
|
||||
Scores based on <b>{{ check.Total.Tests }}</b> tests of HTML and CSS properties using
|
||||
compatibility data from <a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="AboutHTMLCheckResults" tabindex="-1" aria-labelledby="AboutHTMLCheckResultsLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="AboutHTMLCheckResultsLabel">About HTML check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
HTML check is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
<div class="accordion" id="HTMLCheckAboutAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col1" aria-expanded="false" aria-controls="col1">
|
||||
What is HTML check?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col1" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
The support for HTML/CSS messages varies greatly across email clients. HTML check
|
||||
attempts to calculate the overall support for your email for all selected platforms
|
||||
to give you some idea of the general compatibility of your HTML email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col2" aria-expanded="false" aria-controls="col2">
|
||||
How does it work?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col2" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
Internally the original HTML message is run against
|
||||
<b>{{ check.Total.Tests }}</b> different HTML and CSS tests. All tests
|
||||
(except for <code><script></code>) correspond to a test on
|
||||
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and the
|
||||
final score is calculated using the available compatibility data.
|
||||
</p>
|
||||
<p>
|
||||
CSS support is very difficult to programmatically test, especially if a message
|
||||
contains CSS style blocks or is linked to remote stylesheets. Remote stylesheets
|
||||
are, unless blocked via <code>--block-remote-css-and-fonts</code>, downloaded
|
||||
and injected into the message as style blocks. The email is then
|
||||
<a href="https://github.com/vanng822/go-premailer" target="_blank">inlined</a>
|
||||
to matching HTML elements. This gives Mailpit fairly accurate results.
|
||||
</p>
|
||||
<p>
|
||||
CSS properties such as <code>@font-face</code>, <code>:visited</code>,
|
||||
<code>:hover</code> etc cannot be inlined however, so these are searched for
|
||||
within CSS blocks. This method is not accurate as Mailpit does not know how many
|
||||
nodes it actually applies to, if any, so they are weighted lightly (5%) as not
|
||||
to affect the score. An example of this would be any email linking to the full
|
||||
bootstrap CSS which contains dozens of unused attributes.
|
||||
</p>
|
||||
<p>
|
||||
All warnings are displayed with their respective support, including any specific
|
||||
notes, and it is up to you to decide what you do with that information and how
|
||||
badly it may impact your message.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col3" aria-expanded="false" aria-controls="col3">
|
||||
Is the final score accurate?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col3" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
<p>
|
||||
There are many ways to define "accurate", and how one should calculate the
|
||||
compatibility score of an email. There is also no way to programmatically
|
||||
determine the relevance of a single test to the entire email.
|
||||
</p>
|
||||
<p>
|
||||
For each test, Mailpit calculates both the unsupported & partially-supported
|
||||
percentages in relation to the number of matches against the total number of
|
||||
nodes (elements) in the HTML. The maximum unsupported and partially-supported
|
||||
weighted scores are then used for the final score (ie: worst case scenario).
|
||||
</p>
|
||||
<p>
|
||||
To try explain this logic in very simple terms: Assuming a
|
||||
<code><script></code> node (element) has 100% failure (not supported in
|
||||
any email client), and a <code><p></code> node has 100% pass (supported).
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
An email containing just a single <code><script></code>: the final
|
||||
score is 0% supported.
|
||||
</li>
|
||||
<li>
|
||||
An email containing just a <code><script></code> and a
|
||||
<code><p></code>: the final score is 50% supported.
|
||||
</li>
|
||||
<li>
|
||||
An email containing just a <code><script></code> and two
|
||||
<code><p></code>: the final score is 66.67% supported.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Mailpit will sort the warnings according to their weighted unsupported scores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#col4" aria-expanded="false" aria-controls="col4">
|
||||
What about invalid HTML?
|
||||
</button>
|
||||
</h2>
|
||||
<div id="col4" class="accordion-collapse collapse"
|
||||
data-bs-parent="#HTMLCheckAboutAccordion">
|
||||
<div class="accordion-body">
|
||||
HTML check does not detect if the original HTML is valid. In order to detect applied
|
||||
styles to every node, the HTML email is run through a parser which is very good at
|
||||
turning invalid input into valid output. It is what it is...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal fade" id="HTMLCheckOptions" tabindex="-1" aria-labelledby="HTMLCheckOptionsLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="HTMLCheckOptionsLabel">About HTML check</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
HTML check is currently in beta. Constructive feedback is welcome via
|
||||
<a href="https://github.com/axllent/mailpit/issues" target="_blank">GitHub</a>.
|
||||
</p>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" role="switch" v-model="enabled"
|
||||
id="HTMLCheckSwitch">
|
||||
<label class="form-check-label" for="HTMLCheckSwitch">
|
||||
<template v-if="enabled">HTML check is enabled in the web UI</template>
|
||||
<template v-else>HTML check is disabled in the web UI</template>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-4 small text-center text-secondary">
|
||||
HTML check can be globally disabled with <code>--disable-html-check</code><br>
|
||||
Remote CSS and font support can be globally blocked with <code>--block-remote-css-and-fonts</code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -6,7 +6,7 @@ import commonMixins from '../mixins.js'
|
||||
export default {
|
||||
props: {
|
||||
message: Object,
|
||||
relayConfig: Object,
|
||||
uiConfig: Object,
|
||||
releaseAddresses: Array
|
||||
},
|
||||
|
||||
@ -85,16 +85,16 @@ export default {
|
||||
<div class="invalid-feedback">Invalid email address</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text text-center" v-if="relayConfig.MessageRelay.RecipientAllowlist != ''">
|
||||
<div class="form-text text-center" v-if="uiConfig.MessageRelay.RecipientAllowlist != ''">
|
||||
Note: A recipient allowlist has been configured. Any mail address not matching it will be rejected.
|
||||
<br class="d-none d-md-inline">
|
||||
Configured allowlist: <b>{{ relayConfig.MessageRelay.RecipientAllowlist }}</b>
|
||||
Configured allowlist: <b>{{ uiConfig.MessageRelay.RecipientAllowlist }}</b>
|
||||
</div>
|
||||
<div class="form-text text-center">
|
||||
Note: For testing purposes, a unique Message-Id will be generated on send.
|
||||
<br class="d-none d-md-inline">
|
||||
SMTP delivery failures will bounce back to
|
||||
<b v-if="relayConfig.MessageRelay.ReturnPath != ''">{{ relayConfig.MessageRelay.ReturnPath }}</b>
|
||||
<b v-if="uiConfig.MessageRelay.ReturnPath != ''">{{ uiConfig.MessageRelay.ReturnPath }}</b>
|
||||
<b v-else>{{ message.ReturnPath }}</b>.
|
||||
</div>
|
||||
</div>
|
||||
|
@ -122,6 +122,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/message/{ID}/html-check": {
|
||||
"get": {
|
||||
"description": "Returns the summary of HTML check.\n\nNOTE: This feature is currently in beta and is documented for reference only.\nPlease do not integrate with it (yet) as there may be changes.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"tags": [
|
||||
"Other"
|
||||
],
|
||||
"summary": "HTML check (beta)",
|
||||
"operationId": "HTMLCheckResponse",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Database ID",
|
||||
"name": "ID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTMLCheckResponse",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/HTMLCheckResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/responses/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/message/{ID}/part/{PartID}": {
|
||||
"get": {
|
||||
"description": "This will return the attachment part using the appropriate Content-Type.",
|
||||
@ -607,6 +644,183 @@
|
||||
"x-go-name": "deleteRequest",
|
||||
"x-go-package": "github.com/axllent/mailpit/server/apiv1"
|
||||
},
|
||||
"HTMLCheckResponse": {
|
||||
"description": "Response represents the HTML check response struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Platforms": {
|
||||
"description": "All platforms tested, mainly for the web UI",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Total": {
|
||||
"$ref": "#/definitions/HTMLCheckTotal"
|
||||
},
|
||||
"Warnings": {
|
||||
"description": "List of warnings from tests",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/HTMLCheckWarning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-go-name": "Response",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/html-check"
|
||||
},
|
||||
"HTMLCheckResult": {
|
||||
"description": "Result struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Family": {
|
||||
"description": "Family eg: Outlook, Mozilla Thunderbird",
|
||||
"type": "string"
|
||||
},
|
||||
"Name": {
|
||||
"description": "Friendly name of result, combining family, platform \u0026 version",
|
||||
"type": "string"
|
||||
},
|
||||
"NoteNumber": {
|
||||
"description": "Note number for partially supported if applicable",
|
||||
"type": "string"
|
||||
},
|
||||
"Platform": {
|
||||
"description": "Platform eg: ios, android, windows",
|
||||
"type": "string"
|
||||
},
|
||||
"Support": {
|
||||
"description": "Support [yes, no, partial]",
|
||||
"type": "string"
|
||||
},
|
||||
"Version": {
|
||||
"description": "Family version eg: 4.7.1, 2019-10, 10.3",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Result",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/html-check"
|
||||
},
|
||||
"HTMLCheckScore": {
|
||||
"description": "Score struct",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Found": {
|
||||
"description": "Number of matches in the document",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Partial": {
|
||||
"description": "Total percentage partially supported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"Supported": {
|
||||
"description": "Total percentage supported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"Unsupported": {
|
||||
"description": "Total percentage unsupported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Score",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/html-check"
|
||||
},
|
||||
"HTMLCheckTotal": {
|
||||
"description": "Total weighted result for all scores",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Nodes": {
|
||||
"description": "Total number of HTML nodes detected in message",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Partial": {
|
||||
"description": "Overall percentage partially supported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"Supported": {
|
||||
"description": "Overall percentage supported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"Tests": {
|
||||
"description": "Total number of tests done",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"Unsupported": {
|
||||
"description": "Overall percentage unsupported",
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Total",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/html-check"
|
||||
},
|
||||
"HTMLCheckWarning": {
|
||||
"description": "Warning represents a failed test",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Category": {
|
||||
"description": "Category [css, html]",
|
||||
"type": "string"
|
||||
},
|
||||
"Description": {
|
||||
"description": "Description",
|
||||
"type": "string"
|
||||
},
|
||||
"Keywords": {
|
||||
"description": "Keywords",
|
||||
"type": "string"
|
||||
},
|
||||
"NotesByNumber": {
|
||||
"description": "Notes based on results",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Results": {
|
||||
"description": "Test results",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/HTMLCheckResult"
|
||||
}
|
||||
},
|
||||
"Score": {
|
||||
"$ref": "#/definitions/HTMLCheckScore"
|
||||
},
|
||||
"Slug": {
|
||||
"description": "Slug identifier",
|
||||
"type": "string"
|
||||
},
|
||||
"Tags": {
|
||||
"description": "Tags",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Title": {
|
||||
"description": "Friendly title",
|
||||
"type": "string"
|
||||
},
|
||||
"URL": {
|
||||
"description": "URL to caniemail.com",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"x-go-name": "Warning",
|
||||
"x-go-package": "github.com/axllent/mailpit/utils/html-check"
|
||||
},
|
||||
"Message": {
|
||||
"description": "Message data excluding physical attachments",
|
||||
"type": "object",
|
||||
@ -897,6 +1111,10 @@
|
||||
"description": "Response includes global web UI settings",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"DisableHTMLCheck": {
|
||||
"description": "Whether the HTML check has been globally disabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"MessageRelay": {
|
||||
"description": "Message Relay information",
|
||||
"type": "object",
|
||||
|
5
utils/htmlcheck/README.md
Normal file
5
utils/htmlcheck/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# HTML check
|
||||
|
||||
The database used for HTML support tests is based on [can I email](https://www.caniemail.com/).
|
||||
|
||||
The `caniemail-data.json` file used to determine client support is copied from the [API](https://www.caniemail.com/api/data.json)
|
4134
utils/htmlcheck/caniemail-data.json
Normal file
4134
utils/htmlcheck/caniemail-data.json
Normal file
File diff suppressed because it is too large
Load Diff
74
utils/htmlcheck/caniemail.go
Normal file
74
utils/htmlcheck/caniemail.go
Normal file
@ -0,0 +1,74 @@
|
||||
// Package htmlcheck is used for parsing HTML and returning
|
||||
// HTML compatibility errors and warnings
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
//go:embed caniemail-data.json
|
||||
var embeddedFS embed.FS
|
||||
|
||||
var (
|
||||
cie = CanIEmail{}
|
||||
|
||||
noteMatch = regexp.MustCompile(` #(\d)+$`)
|
||||
|
||||
// LimitFamilies will limit results to families if set
|
||||
LimitFamilies = []string{}
|
||||
|
||||
// LimitPlatforms will limit results to platforms if set
|
||||
LimitPlatforms = []string{}
|
||||
|
||||
// LimitClients will limit results to clients if set
|
||||
LimitClients = []string{}
|
||||
)
|
||||
|
||||
// CanIEmail struct for JSON data
|
||||
type CanIEmail struct {
|
||||
APIVersion string `json:"api_version"`
|
||||
LastUpdateDate string `json:"last_update_date"`
|
||||
// NiceNames map[string]string `json:"last_update_date"`
|
||||
NiceNames struct {
|
||||
Family map[string]string `json:"family"`
|
||||
Platform map[string]string `json:"platform"`
|
||||
Support map[string]string `json:"support"`
|
||||
Category map[string]string `json:"category"`
|
||||
} `json:"nicenames"`
|
||||
Data []JSONResult `json:"data"`
|
||||
}
|
||||
|
||||
// JSONResult struct for CanIEmail Data
|
||||
type JSONResult struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Keywords string `json:"keywords"`
|
||||
LastTestDate string `json:"last_test_date"`
|
||||
TestURL string `json:"test_url"`
|
||||
TestResultsURL string `json:"test_results_url"`
|
||||
Stats map[string]interface{} `json:"stats"`
|
||||
Notes string `json:"notes"`
|
||||
NotesByNumber map[string]string `json:"notes_by_num"`
|
||||
}
|
||||
|
||||
// Load the JSON data
|
||||
func loadJSONData() error {
|
||||
if cie.APIVersion != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
b, err := embeddedFS.ReadFile("caniemail-data.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cie = CanIEmail{}
|
||||
|
||||
return json.Unmarshal(b, &cie)
|
||||
}
|
203
utils/htmlcheck/config.go
Normal file
203
utils/htmlcheck/config.go
Normal file
@ -0,0 +1,203 @@
|
||||
package htmlcheck
|
||||
|
||||
import "regexp"
|
||||
|
||||
// HTML tests
|
||||
var htmlTests = map[string]string{
|
||||
// body check is manually done because it always exists in *goquery.Document
|
||||
"html-body": "body",
|
||||
// HTML tests
|
||||
"html-object": "object, embed, image, pdf",
|
||||
"html-link": "link",
|
||||
"html-dialog": "dialog",
|
||||
"html-srcset": "[srcset]",
|
||||
"html-picture": "picture",
|
||||
"html-svg": "svg",
|
||||
"html-progress": "progress",
|
||||
"html-required": "[required]",
|
||||
"html-meter": "meter",
|
||||
"html-audio": "audio",
|
||||
"html-form": "form",
|
||||
"html-input-submit": "submit",
|
||||
"html-button-reset": "button[type=\"reset\"]",
|
||||
"html-button-submit": "submit, button[type=\"submit\"]",
|
||||
"html-base": "base",
|
||||
"html-input-checkbox": "checkbox",
|
||||
"html-input-hidden": "[type=\"hidden\"]",
|
||||
"html-input-radio": "radio",
|
||||
"html-input-text": "input[type=\"text\"]",
|
||||
"html-video": "video",
|
||||
"html-semantics": "article, aside, details, figcaption, figure, footer, header, main, mark, nav, section, summary, time",
|
||||
"html-select": "select",
|
||||
"html-textarea": "textarea",
|
||||
"html-anchor-links": "a[href^=\"#\"]",
|
||||
"html-style": "style",
|
||||
"html-image-maps": "map, img[usemap]",
|
||||
}
|
||||
|
||||
// Image tests using regex to match against img[src]
|
||||
var imageRegexpTests = map[string]*regexp.Regexp{
|
||||
"image-apng": regexp.MustCompile(`(?i)\.apng$`), // 78.723404
|
||||
"image-avif": regexp.MustCompile(`(?i)\.avif$`), // 14.864864
|
||||
"image-base64": regexp.MustCompile(`^(?i)data:image\/`), // 61.702126
|
||||
"image-bmp": regexp.MustCompile(`(?i)\.bmp$`), // 89.3617
|
||||
"image-gif": regexp.MustCompile(`(?i)\.gif$`), // 89.3617
|
||||
"image-hdr": regexp.MustCompile(`(?i)\.hdr$`), // 12.5
|
||||
"image-heif": regexp.MustCompile(`(?i)\.heif$`), // 0
|
||||
"image-ico": regexp.MustCompile(`(?i)\.ico$`), // 87.23404
|
||||
"image-mp4": regexp.MustCompile(`(?i)\.mp4$`), // 26.53061
|
||||
"image-ppm": regexp.MustCompile(`(?i)\.ppm$`), // 2.0833282
|
||||
"image-svg": regexp.MustCompile(`(?i)\.svg$`), // 64.91228
|
||||
"image-tiff": regexp.MustCompile(`(?i)\.tiff?$`), // 38.29787
|
||||
"image-webp": regexp.MustCompile(`(?i)\.webp$`), // 59.649124
|
||||
}
|
||||
|
||||
var cssInlineTests = map[string]string{
|
||||
"css-accent-color": "[style*=\"accent-color:\"]", // 6.6666718
|
||||
"css-align-items": "[style*=\"align-items:\"]", // 60.784313
|
||||
"css-aspect-ratio": "[style*=\"aspect-ratio:\"]", // 30
|
||||
"css-background-blend-mode": "[style*=\"background-blend-mode:\"]", // 61.70213
|
||||
"css-background-clip": "[style*=\"background-clip:\"]", // 61.70213
|
||||
"css-background-color": "[style*=\"background-color:\"], [bgcolor]", // 90
|
||||
"css-background-image": "[style*=\"background-image:\"]", // 57.62712
|
||||
"css-background-origin": "[style*=\"background-origin:\"]", // 61.70213
|
||||
"css-background-position": "[style*=\"background-position:\"]", // 61.224487
|
||||
"css-background-repeat": "[style*=\"background-repeat:\"]", // 67.34694
|
||||
"css-background-size": "[style*=\"background-size:\"]", // 61.702126
|
||||
"css-background": "[style*=\"background:\"], [background]", // 57.407406
|
||||
"css-block-inline-size": "[style*=\"block-inline-size:\"]", // 46.93877
|
||||
"css-border-image": "[style*=\"border-image:\"]", // 52.173912
|
||||
"css-border-inline-block-individual": "[style*=\"border-inline:\"]", // 18.518517
|
||||
"css-border-radius": "[style*=\"border-radius:\"]", // 67.34694
|
||||
"css-border": "[style*=\"border:\"], [border]", // 86.95652
|
||||
"css-box-shadow": "[style*=\"box-shadow:\"]", // 43.103447
|
||||
"css-box-sizing": "[style*=\"box-sizing:\"]", // 71.739136
|
||||
"css-caption-side": "[style*=\"caption-side:\"]", // 84
|
||||
"css-clip-path": "[style*=\"clip-path:\"]", // 43.396225
|
||||
"css-column-count": "[style*=\"column-count:\"]", // 67.391304
|
||||
"css-column-layout-properties": "[style*=\"column-layout-properties:\"]", // 47.368423
|
||||
"css-conic-gradient": "[style*=\"conic-gradient:\"]", // 38.461536
|
||||
"css-direction": "[style*=\"direction:\"]", // 97.77778
|
||||
"css-display-flex": "[style*=\"display:flex\"]", // 53.448277
|
||||
"css-display-grid": "[style*=\"display:grid\"]", // 54.347824
|
||||
"css-display-none": "[style*=\"display:none\"]", // 84.78261
|
||||
"css-display": "[style*=\"display:\"]", // 55.555553
|
||||
"css-filter": "[style*=\"filter:\"]", // 50
|
||||
"css-flex-direction": "[style*=\"flex-direction:\"]", // 50
|
||||
"css-flex-wrap": "[style*=\"flex-wrap:\"]", // 49.09091
|
||||
"css-float": "[style*=\"float:\"]", // 85.10638
|
||||
"css-font-kerning": "[style*=\"font-kerning:\"]", // 66.666664
|
||||
"css-font-weight": "[style*=\"font-weight:\"]", // 76.666664
|
||||
"css-font": "[style*=\"font:\"]", // 95.833336
|
||||
"css-gap": "[style*=\"gap:\"]", // 40
|
||||
"css-grid-template": "[style*=\"grid-template:\"]", // 34.042553
|
||||
"css-height": "[style*=\"height:\"], [height]", // 77.08333
|
||||
"css-hyphens": "[style*=\"hyphens:\"]", // 31.111107
|
||||
"css-important": "[style*=\"!important\"]", // 43.478264
|
||||
"css-inline-size": "[style*=\"inline-size:\"]", // 43.478264
|
||||
"css-intrinsic-size": "[style*=\"intrinsic-size:\"]", // 40.54054
|
||||
"css-justify-content": "[style*=\"justify-content:\"]", // 59.25926
|
||||
"css-letter-spacing": "[style*=\"letter-spacing:\"]", // 87.23404
|
||||
"css-line-height": "[style*=\"line-height:\"]", // 82.608696
|
||||
"css-list-style-image": "[style*=\"list-style-image:\"]", // 54.16667
|
||||
"css-list-style-position": "[style*=\"list-style-position:\"]", // 87.5
|
||||
"css-list-style": "[style*=\"list-style:\"]", // 62.500004
|
||||
"css-margin-block-start-end": "[style*=\"margin-block-start:\"], [style*=\"margin-block-end:\"]", // 32.07547
|
||||
"css-margin-inline-block": "[style*=\"margin-inline-block:\"]", // 16.981125
|
||||
"css-margin-inline-start-end": "[style*=\"margin-inline-start:\"], [style*=\"margin-inline-end:\"]", // 32.07547
|
||||
"css-margin-inline": "[style*=\"margin-inline:\"]", // 43.39623
|
||||
"css-margin": "[style*=\"margin:\"]", // 71.42857
|
||||
"css-max-block-size": "[style*=\"max-block-size:\"]", // 35.714287
|
||||
"css-max-height": "[style*=\"max-height:\"]", // 86.95652
|
||||
"css-max-width": "[style*=\"max-width:\"]", // 76.47058
|
||||
"css-min-height": "[style*=\"min-height:\"]", // 82.608696
|
||||
"css-min-inline-size": "[style*=\"min-inline-size:\"]", // 33.33333
|
||||
"css-min-width": "[style*=\"min-width:\"]", // 86.95652
|
||||
"css-mix-blend-mode": "[style*=\"mix-blend-mode:\"]", // 62.745094
|
||||
"css-modern-color": "[style*=\"modern-color:\"]", // 10.81081
|
||||
"css-object-fit": "[style*=\"object-fit:\"]", // 57.142857
|
||||
"css-object-position": "[style*=\"object-position:\"]", // 55.10204
|
||||
"css-opacity": "[style*=\"opacity:\"]", // 63.04348
|
||||
"css-outline-offset": "[style*=\"outline-offset:\"]", // 42.5
|
||||
"css-outline": "[style*=\"outline:\"]", // 80.85106
|
||||
"css-overflow-wrap": "[style*=\"overflow-wrap:\"]", // 6.6666603
|
||||
"css-overflow": "[style*=\"overflow:\"]", // 78.26087
|
||||
"css-padding-block-start-end": "[style*=\"padding-block-start:\"], [style*=\"padding-block-end:\"]", // 32.07547
|
||||
"css-padding-inline-block": "[style*=\"padding-inline-block:\"]", // 16.981125
|
||||
"css-padding-inline-start-end": "[style*=\"padding-inline-start:\"], [style*=\"padding-inline-end:\"]", // 32.07547
|
||||
"css-padding": "[style*=\"padding:\"], [padding]", // 87.755104
|
||||
"css-position": "[style*=\"position:\"]", // 19.56522
|
||||
"css-radial-gradient": "[style*=\"radial-gradient:\"]", // 64.583336
|
||||
"css-rgb": "[style*=\"rgb(\"]", // 53.846153
|
||||
"css-rgba": "[style*=\"rgba(\"]", // 56
|
||||
"css-scroll-snap": "[style*=\"roll-snap:\"]", // 38.88889
|
||||
"css-tab-size": "[style*=\"tab-size:\"]", // 32.075474
|
||||
"css-table-layout": "[style*=\"table-layout:\"]", // 53.33333
|
||||
"css-text-align-last": "[style*=\"text-align-last:\"]", // 42.307693
|
||||
"css-text-align": "[style*=\"text-align:\"]", // 60.416664
|
||||
"css-text-decoration-color": "[style*=\"text-decoration-color:\"]", // 67.34695
|
||||
"css-text-decoration-thickness": "[style*=\"text-decoration-thickness:\"]", // 38.333336
|
||||
"css-text-decoration": "[style*=\"text-decoration:\"]", // 67.391304
|
||||
"css-text-emphasis-position": "[style*=\"text-emphasis-position:\"]", // 28.571434
|
||||
"css-text-emphasis": "[style*=\"text-emphasis:\"]", // 36.734695
|
||||
"css-text-indent": "[style*=\"text-indent:\"]", // 78.43137
|
||||
"css-text-overflow": "[style*=\"text-overflow:\"]", // 58.695656
|
||||
"css-text-shadow": "[style*=\"text-shadow:\"]", // 69.565216
|
||||
"css-text-transform": "[style*=\"text-transform:\"]", // 86.666664
|
||||
"css-text-underline-offset": "[style*=\"text-underline-offset:\"]", // 39.285713
|
||||
"css-transform": "[style*=\"transform:\"]", // 50
|
||||
"css-unit-calc": "[style*=\"calc(:\"]", // 56.25
|
||||
"css-variables": "[style*=\"variables:\"]", // 46.551727
|
||||
"css-visibility": "[style*=\"visibility:\"]", // 52.173916
|
||||
"css-white-space": "[style*=\"white-space:\"]", // 58.69565
|
||||
"css-width": "[style*=\"width:\"], [width]", // 87.5
|
||||
"css-word-break": "[style*=\"word-break:\"]", // 28.888887
|
||||
"css-writing-mode": "[style*=\"writing-mode:\"]", // 56.25
|
||||
"css-z-index": "[style*=\"z-index:\"]", // 76.08696
|
||||
}
|
||||
|
||||
// some CSS tests using regex for things that can't be merged inline
|
||||
var cssRegexpTests = map[string]*regexp.Regexp{
|
||||
"css-at-font-face": regexp.MustCompile(`(?mi)@font\-face\s+?{`), // 26.923073
|
||||
"css-at-import": regexp.MustCompile(`(?mi)@import\s`), // 36.170216
|
||||
"css-at-keyframes": regexp.MustCompile(`(?mi)@keyframes\s`), // 31.914898
|
||||
"css-at-media": regexp.MustCompile(`(?mi)@media\s?\(`), // 47.05882
|
||||
"css-at-supports": regexp.MustCompile(`(?mi)@supports\s?\(`), // 40.81633
|
||||
"css-pseudo-class-active": regexp.MustCompile(`:active`), // 52.173912
|
||||
"css-pseudo-class-checked": regexp.MustCompile(`:checked`), // 31.91489
|
||||
"css-pseudo-class-first-child": regexp.MustCompile(`:first\-child`), // 66.666664
|
||||
"css-pseudo-class-first-of-type": regexp.MustCompile(`:first\-of\-type`), // 62.5
|
||||
"css-pseudo-class-focus": regexp.MustCompile(`:focus`), // 47.826088
|
||||
"css-pseudo-class-has": regexp.MustCompile(`:has`), // 25.531914
|
||||
"css-pseudo-class-hover": regexp.MustCompile(`:hover`), // 60.41667
|
||||
"css-pseudo-class-lang": regexp.MustCompile(`:lang\s?\(`), // 18.918922
|
||||
"css-pseudo-class-last-child": regexp.MustCompile(`:last\-child`), // 64.58333
|
||||
"css-pseudo-class-last-of-type": regexp.MustCompile(`:last\-of\-type`), // 60.416664
|
||||
"css-pseudo-class-link": regexp.MustCompile(`:link`), // 81.63265
|
||||
"css-pseudo-class-not": regexp.MustCompile(`:not(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-child": regexp.MustCompile(`:nth\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-child": regexp.MustCompile(`:nth\-last\-child(\s+)?\(`), // 44.89796
|
||||
"css-pseudo-class-nth-last-of-type": regexp.MustCompile(`:nth\-last\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-nth-of-type": regexp.MustCompile(`:nth\-of\-type(\s+)?\(`), // 42.857143
|
||||
"css-pseudo-class-only-child": regexp.MustCompile(`:only\-child(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-only-of-type": regexp.MustCompile(`:only\-of\-type(\s+)?\(`), // 64.58333
|
||||
"css-pseudo-class-target": regexp.MustCompile(`:target`), // 39.13044
|
||||
"css-pseudo-class-visited": regexp.MustCompile(`:visited`), // 39.13044
|
||||
"css-pseudo-element-after": regexp.MustCompile(`:after`), // 40
|
||||
"css-pseudo-element-before": regexp.MustCompile(`:before`), // 40
|
||||
"css-pseudo-element-first-letter": regexp.MustCompile(`::first\-letter`), // 60
|
||||
"css-pseudo-element-first-line": regexp.MustCompile(`::first\-line`), // 60
|
||||
"css-pseudo-element-marker": regexp.MustCompile(`::marker`), // 50
|
||||
"css-pseudo-element-placeholder": regexp.MustCompile(`::placeholder`), // 32
|
||||
}
|
||||
|
||||
// some CSS tests using regex for units
|
||||
var cssRegexpUnitTests = map[string]*regexp.Regexp{
|
||||
"css-unit-ch": regexp.MustCompile(`\b\d+ch\b`), // 66.666664
|
||||
"css-unit-initial": regexp.MustCompile(`:\s?initial\b`), // 58.33333
|
||||
"css-unit-rem": regexp.MustCompile(`\b\d+rem\b`), // 66.666664
|
||||
"css-unit-vh": regexp.MustCompile(`\b\d+vh\b`), // 68.75
|
||||
"css-unit-vmax": regexp.MustCompile(`\b\d+vmax\b`), // 60.416664
|
||||
"css-unit-vmin": regexp.MustCompile(`\b\d+vmin\b`), // 58.333336
|
||||
"css-unit-vw": regexp.MustCompile(`\b\d+vw\b`), // 77.08333
|
||||
}
|
218
utils/htmlcheck/css.go
Normal file
218
utils/htmlcheck/css.go
Normal file
@ -0,0 +1,218 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/utils/logger"
|
||||
"github.com/vanng822/go-premailer/premailer"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
// Go cannot calculate any rendered CSS attributes, so we merge all styles
|
||||
// into the HTML and detect elements with styles containing the keywords.
|
||||
func runCSSTests(html string) ([]Warning, int, error) {
|
||||
results := []Warning{}
|
||||
totalTests := 0
|
||||
|
||||
inlined, err := inlineRemoteCSS(html)
|
||||
if err != nil {
|
||||
// logger.Log().Warn(err)
|
||||
inlined = html
|
||||
}
|
||||
|
||||
// merge all CSS inline
|
||||
merged, err := mergeInlineCSS(inlined)
|
||||
if err != nil {
|
||||
// logger.Log().Warn(err)
|
||||
merged = inlined
|
||||
}
|
||||
|
||||
reader := strings.NewReader(merged)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
for key, test := range cssInlineTests {
|
||||
totalTests++
|
||||
found := len(doc.Find(test).Nodes)
|
||||
if found > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// get a list of all generated styles from all nodes
|
||||
allNodeStyles := []string{}
|
||||
for _, n := range doc.Find("*[style]").Nodes {
|
||||
style, err := getHTMLAttributeVal(n, "style")
|
||||
if err == nil {
|
||||
allNodeStyles = append(allNodeStyles, style)
|
||||
}
|
||||
}
|
||||
|
||||
for key, re := range cssRegexpUnitTests {
|
||||
totalTests++
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
found := 0
|
||||
// loop through all styles to count total
|
||||
for _, styles := range allNodeStyles {
|
||||
found = found + len(re.FindAllString(styles, -1))
|
||||
}
|
||||
|
||||
if found > 0 {
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// get all inline CSS block data
|
||||
reader = strings.NewReader(inlined)
|
||||
|
||||
// Load the HTML document
|
||||
doc, _ = goquery.NewDocumentFromReader(reader)
|
||||
|
||||
cssCode := ""
|
||||
for _, n := range doc.Find("style").Nodes {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
cssCode = cssCode + c.Data
|
||||
}
|
||||
}
|
||||
|
||||
for key, re := range cssRegexpTests {
|
||||
totalTests++
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
found := len(re.FindAllString(cssCode, -1))
|
||||
if found > 0 {
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, totalTests, nil
|
||||
}
|
||||
|
||||
// MergeInlineCSS merges header CSS into element attributes
|
||||
func mergeInlineCSS(html string) (string, error) {
|
||||
options := premailer.NewOptions()
|
||||
// options.RemoveClasses = true
|
||||
// options.CssToAttributes = false
|
||||
options.KeepBangImportant = true
|
||||
pre, err := premailer.NewPremailerFromString(html, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return pre.Transform()
|
||||
}
|
||||
|
||||
// InlineRemoteCSS searches the HTML for linked stylesheets, downloads the content, and
|
||||
// inserts new <style> blocks into the head, unless BlockRemoteCSSAndFonts is set
|
||||
func inlineRemoteCSS(h string) (string, error) {
|
||||
reader := strings.NewReader(h)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return h, err
|
||||
}
|
||||
|
||||
remoteCSS := doc.Find("link[rel=\"stylesheet\"]").Nodes
|
||||
for _, link := range remoteCSS {
|
||||
attributes := link.Attr
|
||||
for _, a := range attributes {
|
||||
if a.Key == "href" {
|
||||
if !isURL(a.Val) {
|
||||
// skip invalid URL
|
||||
continue
|
||||
}
|
||||
|
||||
if config.BlockRemoteCSSAndFonts {
|
||||
logger.Log().Debugf("[html-check] skip testing remote CSS content: %s (--block-remote-css-and-fonts)", a.Val)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
resp, err := downloadToBytes(a.Val)
|
||||
if err != nil {
|
||||
logger.Log().Warningf("html check failed to download %s", a.Val)
|
||||
continue
|
||||
}
|
||||
|
||||
// create new <style> block and insert downloaded CSS
|
||||
styleBlock := &html.Node{
|
||||
Type: html.ElementNode,
|
||||
Data: "style",
|
||||
DataAtom: atom.Style,
|
||||
}
|
||||
styleBlock.AppendChild(&html.Node{
|
||||
Type: html.TextNode,
|
||||
Data: string(resp),
|
||||
})
|
||||
|
||||
link.Parent.AppendChild(styleBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newDoc, err := doc.Html()
|
||||
if err != nil {
|
||||
logger.Log().Warning(err)
|
||||
return h, err
|
||||
}
|
||||
|
||||
return newDoc, nil
|
||||
}
|
||||
|
||||
// DownloadToBytes returns a []byte slice from a URL
|
||||
func downloadToBytes(url string) ([]byte, error) {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Get the link response data
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
err := fmt.Errorf("Error downloading %s", url)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Test if str is a URL
|
||||
func isURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
112
utils/htmlcheck/html.go
Normal file
112
utils/htmlcheck/html.go
Normal file
@ -0,0 +1,112 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// HTML tests
|
||||
func runHTMLTests(html string) ([]Warning, int, error) {
|
||||
results := []Warning{}
|
||||
totalTests := 0
|
||||
|
||||
reader := strings.NewReader(html)
|
||||
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
// Almost all <script> is bad
|
||||
scripts := len(doc.Find("script:not([type=\"application/ld+json\"])").Nodes)
|
||||
if scripts > 0 {
|
||||
var result = Warning{}
|
||||
result.Title = "<script> element"
|
||||
result.Slug = "html-script"
|
||||
result.Category = "html"
|
||||
result.Description = "JavaScript is not supported in any email client."
|
||||
result.Tags = []string{}
|
||||
result.Results = []Result{}
|
||||
result.NotesByNumber = map[string]string{}
|
||||
result.Score.Found = scripts
|
||||
result.Score.Supported = 0
|
||||
result.Score.Partial = 0
|
||||
result.Score.Unsupported = 100
|
||||
results = append(results, result)
|
||||
totalTests++
|
||||
}
|
||||
|
||||
for key, test := range htmlTests {
|
||||
totalTests++
|
||||
if test == "body" {
|
||||
re := regexp.MustCompile(`(?im)</body>`)
|
||||
if re.MatchString(html) {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
|
||||
result.Score.Found = 1
|
||||
results = append(results, result)
|
||||
}
|
||||
} else if len(doc.Find(test).Nodes) > 0 {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
totalTests++
|
||||
|
||||
result.Score.Found = len(doc.Find(test).Nodes)
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// find all images
|
||||
images := doc.Find("img[src]").Nodes
|
||||
imageResults := make(map[string]int)
|
||||
totalTests = totalTests + len(imageRegexpTests)
|
||||
|
||||
for _, image := range images {
|
||||
src, err := getHTMLAttributeVal(image, "src")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for key, test := range imageRegexpTests {
|
||||
if test.MatchString(src) {
|
||||
matches, exists := imageResults[key]
|
||||
if exists {
|
||||
imageResults[key] = matches + 1
|
||||
} else {
|
||||
imageResults[key] = 1
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, found := range imageResults {
|
||||
result, err := cie.getTest(key)
|
||||
if err != nil {
|
||||
return results, totalTests, err
|
||||
}
|
||||
result.Score.Found = found
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, totalTests, nil
|
||||
}
|
||||
|
||||
func getHTMLAttributeVal(e *html.Node, key string) (string, error) {
|
||||
for _, a := range e.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
212
utils/htmlcheck/main.go
Normal file
212
utils/htmlcheck/main.go
Normal file
@ -0,0 +1,212 @@
|
||||
package htmlcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
)
|
||||
|
||||
// RunTests will run all tests on an HTML string
|
||||
func RunTests(html string) (Response, error) {
|
||||
s := Response{}
|
||||
s.Warnings = []Warning{}
|
||||
if platforms, err := Platforms(); err == nil {
|
||||
s.Platforms = platforms
|
||||
}
|
||||
|
||||
s.Total = Total{}
|
||||
|
||||
// crude way to determine whether the HTML contains a <body> structure
|
||||
// or whether it's just plain HTML content
|
||||
re := regexp.MustCompile(`(?im)</body>`)
|
||||
nodeMatch := "body *, script"
|
||||
if re.MatchString(html) {
|
||||
nodeMatch = "*:not(html):not(head):not(meta), script"
|
||||
}
|
||||
reader := strings.NewReader(html)
|
||||
// Load the HTML document
|
||||
doc, err := goquery.NewDocumentFromReader(reader)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
// calculate the number of nodes in HTML
|
||||
s.Total.Nodes = len(doc.Find(nodeMatch).Nodes)
|
||||
|
||||
if err := loadJSONData(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
// HTML tests
|
||||
htmlResults, totalTests, err := runHTMLTests(html)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Total.Tests = s.Total.Tests + totalTests
|
||||
|
||||
// add html test totals
|
||||
s.Warnings = append(s.Warnings, htmlResults...)
|
||||
|
||||
// CSS tests
|
||||
cssResults, totalTests, err := runCSSTests(html)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
|
||||
s.Total.Tests = s.Total.Tests + totalTests
|
||||
|
||||
// add css test totals
|
||||
s.Warnings = append(s.Warnings, cssResults...)
|
||||
|
||||
// calculate total score
|
||||
var partial, unsupported float32
|
||||
partial = 0
|
||||
unsupported = 0
|
||||
|
||||
for _, w := range s.Warnings {
|
||||
if w.Score.Found == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// supported is calculated by subtracting partial and unsupported from 100%
|
||||
if w.Score.Partial > 0 {
|
||||
weighted := w.Score.Partial * float32(w.Score.Found) / float32(s.Total.Nodes)
|
||||
if weighted > partial {
|
||||
partial = weighted
|
||||
}
|
||||
}
|
||||
if w.Score.Unsupported > 0 {
|
||||
weighted := w.Score.Unsupported * float32(w.Score.Found) / float32(s.Total.Nodes)
|
||||
if weighted > unsupported {
|
||||
unsupported = weighted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.Total.Supported = 100 - partial - unsupported
|
||||
s.Total.Partial = partial
|
||||
s.Total.Unsupported = unsupported
|
||||
|
||||
// sort slice to get lowest scores first
|
||||
sort.Slice(s.Warnings, func(i, j int) bool {
|
||||
return (s.Warnings[i].Score.Unsupported+s.Warnings[i].Score.Partial)*float32(s.Warnings[i].Score.Found)/float32(s.Total.Nodes) >
|
||||
(s.Warnings[j].Score.Unsupported+s.Warnings[j].Score.Partial)*float32(s.Warnings[j].Score.Found)/float32(s.Total.Nodes)
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Test returns a test
|
||||
func (c CanIEmail) getTest(k string) (Warning, error) {
|
||||
warning := Warning{}
|
||||
exists := false
|
||||
found := JSONResult{}
|
||||
for _, r := range cie.Data {
|
||||
if r.Slug == k {
|
||||
found = r
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return warning, fmt.Errorf("%s does not exist", k)
|
||||
}
|
||||
|
||||
warning.Slug = found.Slug
|
||||
warning.Title = found.Title
|
||||
warning.Description = mdToHTML(found.Description)
|
||||
warning.Category = found.Category
|
||||
warning.URL = found.URL
|
||||
warning.Tags = found.Tags
|
||||
// warning.Keywords = found.Keywords
|
||||
// warning.Notes = found.Notes
|
||||
warning.NotesByNumber = make(map[string]string, len(found.NotesByNumber))
|
||||
for nr, note := range found.NotesByNumber {
|
||||
warning.NotesByNumber[nr] = mdToHTML(note)
|
||||
}
|
||||
warning.Results = []Result{}
|
||||
|
||||
var y, n, p float32
|
||||
|
||||
for family, stats := range found.Stats {
|
||||
if len(LimitFamilies) != 0 && !inArray(family, LimitFamilies) {
|
||||
continue
|
||||
}
|
||||
|
||||
for platform, clients := range stats.(map[string]interface{}) {
|
||||
if len(LimitPlatforms) != 0 && !inArray(platform, LimitPlatforms) {
|
||||
continue
|
||||
}
|
||||
for version, support := range clients.(map[string]interface{}) {
|
||||
s := Result{}
|
||||
s.Name = fmt.Sprintf("%s %s (%s)", c.NiceNames.Family[family], c.NiceNames.Platform[platform], version)
|
||||
s.Family = family
|
||||
s.Platform = platform
|
||||
s.Version = version
|
||||
|
||||
if support == "y" {
|
||||
y++
|
||||
s.Support = "yes"
|
||||
} else if support == "n" {
|
||||
n++
|
||||
s.Support = "no"
|
||||
} else {
|
||||
p++
|
||||
s.Support = "partial"
|
||||
|
||||
noteIDS := noteMatch.FindStringSubmatch(fmt.Sprintf("%s", support))
|
||||
|
||||
for _, id := range noteIDS {
|
||||
s.NoteNumber = id
|
||||
}
|
||||
}
|
||||
|
||||
warning.Results = append(warning.Results, s)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
total := y + n + p
|
||||
warning.Score.Supported = y / total * 100
|
||||
warning.Score.Unsupported = n / total * 100
|
||||
warning.Score.Partial = p / total * 100
|
||||
|
||||
return warning, nil
|
||||
}
|
||||
|
||||
func inArray(n string, h []string) bool {
|
||||
n = strings.ToLower(n)
|
||||
|
||||
for _, v := range h {
|
||||
if strings.ToLower(v) == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert markdown to HTML, stripping <p> & </p>
|
||||
func mdToHTML(str string) string {
|
||||
md := []byte(str)
|
||||
// create markdown parser with extensions
|
||||
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
|
||||
// extensions := parser.NoExtensions
|
||||
p := parser.NewWithExtensions(extensions)
|
||||
doc := p.Parse(md)
|
||||
|
||||
// create HTML renderer with extensions
|
||||
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||
opts := html.RendererOptions{Flags: htmlFlags}
|
||||
renderer := html.NewRenderer(opts)
|
||||
|
||||
return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(string(markdown.Render(doc, renderer))), "<p>"), "</p>")
|
||||
}
|
38
utils/htmlcheck/platforms.go
Normal file
38
utils/htmlcheck/platforms.go
Normal file
@ -0,0 +1,38 @@
|
||||
package htmlcheck
|
||||
|
||||
import "sort"
|
||||
|
||||
// Platforms returns all platforms with their respective email clients
|
||||
func Platforms() (map[string][]string, error) {
|
||||
// [platform]clients
|
||||
data := make(map[string][]string)
|
||||
|
||||
if err := loadJSONData(); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
for _, t := range cie.Data {
|
||||
for family, stats := range t.Stats {
|
||||
niceFamily := cie.NiceNames.Family[family]
|
||||
for platform := range stats.(map[string]interface{}) {
|
||||
c, found := data[platform]
|
||||
if !found {
|
||||
data[platform] = []string{}
|
||||
}
|
||||
if !inArray(niceFamily, c) {
|
||||
c = append(c, niceFamily)
|
||||
data[platform] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for group, clients := range data {
|
||||
sort.Slice(clients, func(i, j int) bool {
|
||||
return clients[i] < clients[j]
|
||||
})
|
||||
data[group] = clients
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
87
utils/htmlcheck/structs.go
Normal file
87
utils/htmlcheck/structs.go
Normal file
@ -0,0 +1,87 @@
|
||||
package htmlcheck
|
||||
|
||||
// Response represents the HTML check response struct
|
||||
//
|
||||
// swagger:model HTMLCheckResponse
|
||||
type Response struct {
|
||||
// List of warnings from tests
|
||||
Warnings []Warning `json:"Warnings"`
|
||||
// All platforms tested, mainly for the web UI
|
||||
Platforms map[string][]string `json:"Platforms"`
|
||||
// Total overall score
|
||||
Total Total `json:"Total"`
|
||||
}
|
||||
|
||||
// Warning represents a failed test
|
||||
//
|
||||
// swagger:model HTMLCheckWarning
|
||||
type Warning struct {
|
||||
// Slug identifier
|
||||
Slug string `json:"Slug"`
|
||||
// Friendly title
|
||||
Title string `json:"Title"`
|
||||
// Description
|
||||
Description string `json:"Description"`
|
||||
// URL to caniemail.com
|
||||
URL string `json:"URL"`
|
||||
// Category [css, html]
|
||||
Category string `json:"Category"`
|
||||
// Tags
|
||||
Tags []string `json:"Tags"`
|
||||
// Keywords
|
||||
Keywords string `json:"Keywords"`
|
||||
// Test results
|
||||
Results []Result `json:"Results"`
|
||||
// Notes based on results
|
||||
NotesByNumber map[string]string `json:"NotesByNumber"`
|
||||
// Test score calculated from results
|
||||
Score Score `json:"Score"`
|
||||
}
|
||||
|
||||
// Result struct
|
||||
//
|
||||
// swagger:model HTMLCheckResult
|
||||
type Result struct {
|
||||
// Friendly name of result, combining family, platform & version
|
||||
Name string `json:"Name"`
|
||||
// Platform eg: ios, android, windows
|
||||
Platform string `json:"Platform"`
|
||||
// Family eg: Outlook, Mozilla Thunderbird
|
||||
Family string `json:"Family"`
|
||||
// Family version eg: 4.7.1, 2019-10, 10.3
|
||||
Version string `json:"Version"`
|
||||
// Support [yes, no, partial]
|
||||
Support string `json:"Support"`
|
||||
// Note number for partially supported if applicable
|
||||
NoteNumber string `json:"NoteNumber"` // where applicable
|
||||
}
|
||||
|
||||
// Score struct
|
||||
//
|
||||
// swagger:model HTMLCheckScore
|
||||
type Score struct {
|
||||
// Number of matches in the document
|
||||
Found int `json:"Found"`
|
||||
// Total percentage supported
|
||||
Supported float32 `json:"Supported"`
|
||||
// Total percentage partially supported
|
||||
Partial float32 `json:"Partial"`
|
||||
// Total percentage unsupported
|
||||
Unsupported float32 `json:"Unsupported"`
|
||||
}
|
||||
|
||||
// Total weighted result for all scores
|
||||
//
|
||||
// swagger:model HTMLCheckTotal
|
||||
type Total struct {
|
||||
// Total number of tests done
|
||||
Tests int `json:"Tests"`
|
||||
// Total number of HTML nodes detected in message
|
||||
Nodes int `json:"Nodes"`
|
||||
// Overall percentage supported
|
||||
Supported float32 `json:"Supported"`
|
||||
// Overall percentage partially supported
|
||||
Partial float32 `json:"Partial"` // total percentage
|
||||
// Overall percentage unsupported
|
||||
Unsupported float32 `json:"Unsupported"` // total percentage
|
||||
}
|
Loading…
Reference in New Issue
Block a user