diff --git a/.gitignore b/.gitignore index 35f2a56..ca161d8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,22 @@ webmail.code-workspace graphify-out GEMINI.md -tests/ \ No newline at end of file +tests/ + +android/build/ +android/app/build/ +android/app/release/ +android/.gradle/ +android/.idea/ +android/.kotlin/ +android/captures/ +android/*.iml +android/app/*.iml +android/local.properties +android/keystore.properties +android/*.keystore +android/*.jks +android/*.apk +android/*.aab +android/**/.cxx/ +android/**/.externalNativeBuild/ \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 4b4e91b..4ccfc3f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -143,6 +143,26 @@ func main() { w.Header().Set("Content-Type", "image/png") w.Write(data) }) + // PWA manifest + service worker — served at root (not /static/) so the service worker's + // default scope covers the whole app, not just /static/. + r.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { + data, err := gowebmail.WebFS.ReadFile("web/static/manifest.json") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/manifest+json") + w.Write(data) + }) + r.HandleFunc("/sw.js", func(w http.ResponseWriter, r *http.Request) { + data, err := gowebmail.WebFS.ReadFile("web/static/sw.js") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/javascript") + w.Write(data) + }) // Public auth routes auth := r.PathPrefix("/auth").Subrouter() auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET") @@ -278,6 +298,11 @@ func main() { api.HandleFunc("/accounts/sort-order", h.API.SetAccountSortOrder).Methods("PUT") api.HandleFunc("/ui-prefs", h.API.GetUIPrefs).Methods("GET") api.HandleFunc("/ui-prefs", h.API.SetUIPrefs).Methods("PUT") + + // Web Push (background new-mail notifications) + api.HandleFunc("/push/vapid-public-key", h.API.GetVAPIDPublicKey).Methods("GET") + api.HandleFunc("/push/subscribe", h.API.SubscribePush).Methods("POST") + api.HandleFunc("/push/unsubscribe", h.API.UnsubscribePush).Methods("POST") api.HandleFunc("/login-history", h.API.ListMyLoginHistory).Methods("GET") // Search diff --git a/config/config.go b/config/config.go index dac410c..3929e41 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,8 @@ import ( "os" "strconv" "strings" + + webpush "github.com/SherClockHolmes/webpush-go" ) // Config holds all application configuration. @@ -61,6 +63,10 @@ type Config struct { MicrosoftClientSecret string MicrosoftTenantID string MicrosoftRedirectURL string // auto-derived from BaseURL if blank + + // Web Push (VAPID) — signs background push notifications to browsers/mobile PWAs + VAPIDPublicKey string + VAPIDPrivateKey string } const configPath = "./data/gowebmail.conf" @@ -325,6 +331,20 @@ var allFields = []configField{ "Must exactly match what is registered in Azure.", }, }, + { + key: "VAPID_PUBLIC_KEY", + defVal: "", + comments: []string{ + "--- Web Push Notifications ---", + "VAPID keypair signing background push notifications (browser + mobile PWA/Android wrapper).", + "Auto-generated on first run. Do not edit manually.", + }, + }, + { + key: "VAPID_PRIVATE_KEY", + defVal: "", + comments: []string{}, + }, } // Load reads/creates data/gowebmail.conf, fills in missing keys, then returns Config. @@ -347,6 +367,14 @@ func Load() (*Config, error) { if existing["SESSION_SECRET"] == "" { existing["SESSION_SECRET"] = mustHex(32) } + if existing["VAPID_PUBLIC_KEY"] == "" || existing["VAPID_PRIVATE_KEY"] == "" { + priv, pub, err := webpush.GenerateVAPIDKeys() + if err != nil { + return nil, fmt.Errorf("generate VAPID keys: %w", err) + } + existing["VAPID_PRIVATE_KEY"] = priv + existing["VAPID_PUBLIC_KEY"] = pub + } // Write back (preserves existing, adds any new fields from allFields) if err := writeConfigFile(configPath, existing); err != nil { @@ -463,6 +491,9 @@ func Load() (*Config, error) { MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"), MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "consumers"), MicrosoftRedirectURL: outlookRedirect, + + VAPIDPublicKey: get("VAPID_PUBLIC_KEY"), + VAPIDPrivateKey: get("VAPID_PRIVATE_KEY"), } // Derive SECURE_COOKIE automatically if BASE_URL uses https @@ -652,8 +683,10 @@ func writeConfigFile(path string, values map[string]string) error { // SESSION_SECRET and ENCRYPTION_KEY are intentionally excluded. var EditableKeys = func() map[string]bool { excluded := map[string]bool{ - "SESSION_SECRET": true, - "ENCRYPTION_KEY": true, + "SESSION_SECRET": true, + "ENCRYPTION_KEY": true, + "VAPID_PUBLIC_KEY": true, + "VAPID_PRIVATE_KEY": true, } m := map[string]bool{} for _, f := range allFields { diff --git a/go.mod b/go.mod index 4ba428f..4aa6778 100644 --- a/go.mod +++ b/go.mod @@ -18,8 +18,10 @@ require ( require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/SherClockHolmes/webpush-go v1.4.0 // indirect github.com/cloudflare/circl v1.6.2 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect diff --git a/go.sum b/go.sum index bad5ba1..e30809c 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2Qx cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= @@ -16,24 +18,88 @@ github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHC github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg= go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/internal/db/db.go b/internal/db/db.go index dc18b2a..48032cb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -494,6 +494,23 @@ func (d *DB) Migrate() error { // One-row-per-migration marker table so a one-time data backfill (as opposed to a // schema ALTER, which is naturally idempotent) runs exactly once, ever — never // re-applying and silently overwriting a choice the user made after that first run. + // Web Push subscriptions — a browser/WebView calls PushManager.subscribe() once per + // device and posts the result here; the syncer looks these up by user_id to deliver + // background new-mail notifications via VAPID. Outlives login sessions on purpose. + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at DATETIME DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create push_subscriptions: %w", err) + } + if _, err := d.sql.Exec(`CREATE INDEX IF NOT EXISTS idx_push_subs_user ON push_subscriptions(user_id)`); err != nil { + return fmt.Errorf("create idx_push_subs_user: %w", err) + } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS data_migrations ( name TEXT PRIMARY KEY, applied_at DATETIME DEFAULT (datetime('now')) @@ -3634,3 +3651,68 @@ func (d *DB) GetTrustedCert(accountID int64, fingerprint string) (string, error) } return certPEM, err } + +// ---- Web Push subscriptions ---- + +// PushSubscription is a decrypted row from push_subscriptions, shaped to match +// webpush.Subscription directly (Endpoint + Keys.P256dh/Auth). +type PushSubscription struct { + ID int64 + Endpoint string + P256dh string + Auth string +} + +// UpsertPushSubscription stores (or refreshes) a browser's PushSubscription for userID. +// p256dh/auth are encrypted at rest like other sensitive fields. +func (d *DB) UpsertPushSubscription(userID int64, endpoint, p256dh, auth string) error { + encP256dh, err := d.enc.Encrypt(p256dh) + if err != nil { + return err + } + encAuth, err := d.enc.Encrypt(auth) + if err != nil { + return err + } + _, err = d.sql.Exec(` + INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) + VALUES (?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth`, + userID, endpoint, encP256dh, encAuth) + return err +} + +// DeletePushSubscription removes a subscription by its endpoint, scoped to userID so one +// user can't unsubscribe another's device. +func (d *DB) DeletePushSubscription(userID int64, endpoint string) error { + _, err := d.sql.Exec(`DELETE FROM push_subscriptions WHERE user_id=? AND endpoint=?`, userID, endpoint) + return err +} + +// DeletePushSubscriptionByEndpoint removes a subscription regardless of owner — used when +// the push service reports the endpoint is gone (410/404), so no user_id is known at that point. +func (d *DB) DeletePushSubscriptionByEndpoint(endpoint string) error { + _, err := d.sql.Exec(`DELETE FROM push_subscriptions WHERE endpoint=?`, endpoint) + return err +} + +// GetPushSubscriptionsForUser returns every device subscribed to push for userID. +func (d *DB) GetPushSubscriptionsForUser(userID int64) ([]*PushSubscription, error) { + rows, err := d.sql.Query(`SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id=?`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*PushSubscription + for rows.Next() { + var s PushSubscription + var encP256dh, encAuth string + if err := rows.Scan(&s.ID, &s.Endpoint, &encP256dh, &encAuth); err != nil { + continue + } + s.P256dh, _ = d.enc.Decrypt(encP256dh) + s.Auth, _ = d.enc.Decrypt(encAuth) + out = append(out, &s) + } + return out, rows.Err() +} diff --git a/internal/handlers/push.go b/internal/handlers/push.go new file mode 100644 index 0000000..f0d0e65 --- /dev/null +++ b/internal/handlers/push.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + + webpush "github.com/SherClockHolmes/webpush-go" + "github.com/ghostersk/gowebmail/internal/middleware" +) + +// GetVAPIDPublicKey exposes the server's VAPID public key so the frontend can pass it to +// PushManager.subscribe({applicationServerKey: ...}). +func (h *APIHandler) GetVAPIDPublicKey(w http.ResponseWriter, r *http.Request) { + h.writeJSON(w, map[string]string{"public_key": h.cfg.VAPIDPublicKey}) +} + +// SubscribePush stores a browser/WebView's PushSubscription (from +// PushManager.subscribe().toJSON()) so background new-mail push can reach it. +func (h *APIHandler) SubscribePush(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024)) + if err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + var sub webpush.Subscription + if err := json.Unmarshal(body, &sub); err != nil || sub.Endpoint == "" || sub.Keys.P256dh == "" || sub.Keys.Auth == "" { + h.writeError(w, http.StatusBadRequest, "invalid push subscription") + return + } + if err := h.db.UpsertPushSubscription(userID, sub.Endpoint, sub.Keys.P256dh, sub.Keys.Auth); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to save subscription") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// UnsubscribePush removes a subscription by endpoint (sent when the user disables the +// notifications toggle, or the browser reports the subscription changed/expired). +func (h *APIHandler) UnsubscribePush(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024)) + if err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + var req struct { + Endpoint string `json:"endpoint"` + } + if err := json.Unmarshal(body, &req); err != nil || req.Endpoint == "" { + h.writeError(w, http.StatusBadRequest, "endpoint required") + return + } + if err := h.db.DeletePushSubscription(userID, req.Endpoint); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to remove subscription") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index a552e6b..b1923b7 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -7,12 +7,14 @@ package syncer import ( "context" + "encoding/json" "fmt" "log" "strings" "sync" "time" + webpush "github.com/SherClockHolmes/webpush-go" "github.com/ghostersk/gowebmail/internal/logger" "github.com/ghostersk/gowebmail/config" "github.com/ghostersk/gowebmail/internal/auth" @@ -495,6 +497,7 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db } // 1. Fetch new messages (UID > lastSeenUID) + isIncrementalSync := lastSeenUID != 0 // false = first-ever sync or post-UIDVALIDITY full re-sync var msgs []*models.Message if lastSeenUID == 0 { // First sync: respect the account's days/all setting @@ -510,6 +513,11 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db return 0, fmt.Errorf("fetch new: %w", err) } + // Collected only for a genuine incremental inbox sync — never for first-sync/full-resync + // backfill (that's historical mail, not "new mail") — mirrors the same restraint the + // reconciliation step below already applies to rules/spam-move side effects. + var pushCandidates []*models.Message + maxUID := lastSeenUID for _, msg := range msgs { msg.FolderID = dbFolder.ID @@ -523,6 +531,8 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db s.moveToSpamIMAP(account, dbFolder, msg) } else if rule := matchRule(msg, account.EmailAddress, activeRules); rule != nil { s.applyRuleIMAP(c, account, dbFolder, msg, rule) + } else if isIncrementalSync && dbFolder.FolderType == "inbox" && !msg.IsRead { + pushCandidates = append(pushCandidates, msg) } } uid := uint32(0) @@ -531,6 +541,9 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db maxUID = uid } } + if len(pushCandidates) > 0 { + s.sendNewMailPush(account.UserID, pushCandidates) + } // 2. Sync flags for ALL existing messages (catch read/star changes from other clients) flags, err := c.SyncFlags(dbFolder.FullPath) @@ -601,6 +614,62 @@ func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, db return newMessages, nil } +// sendNewMailPush delivers a background Web Push notification for genuinely new unread +// inbox mail to every device userID has subscribed (Settings > General > Notifications). +// Mirrors the title/body convention already used client-side by sendOSNotification() in +// app.js so a background push and a foreground toast read the same way. +func (s *Scheduler) sendNewMailPush(userID int64, msgs []*models.Message) { + if s.cfg.VAPIDPrivateKey == "" || s.cfg.VAPIDPublicKey == "" { + return + } + subs, err := s.db.GetPushSubscriptionsForUser(userID) + if err != nil || len(subs) == 0 { + return + } + + first := msgs[0] + fromLabel := first.FromName + if fromLabel == "" { + fromLabel = first.FromEmail + } + subject := first.Subject + if subject == "" { + subject = "(no subject)" + } + + title, body := fromLabel, subject + if len(msgs) > 1 { + title = fmt.Sprintf("%d new messages in GoWebMail", len(msgs)) + body = fmt.Sprintf("%s: %s", fromLabel, subject) + } + + payload, err := json.Marshal(map[string]string{"title": title, "body": body, "tag": "gowebmail-new"}) + if err != nil { + return + } + + for _, sub := range subs { + resp, err := webpush.SendNotification(payload, &webpush.Subscription{ + Endpoint: sub.Endpoint, + Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}, + }, &webpush.Options{ + Subscriber: "mailto:noreply@" + s.cfg.Hostname, + VAPIDPublicKey: s.cfg.VAPIDPublicKey, + VAPIDPrivateKey: s.cfg.VAPIDPrivateKey, + TTL: 60, + }) + if err != nil { + log.Printf("[push] send to user %d: %v", userID, err) + continue + } + resp.Body.Close() + if resp.StatusCode == 404 || resp.StatusCode == 410 { + // Subscription is dead (browser data cleared, app uninstalled, etc). + s.db.DeletePushSubscriptionByEndpoint(sub.Endpoint) + } + } +} + // ---- Pending ops drain ---- // Applies queued IMAP write operations (delete/move/flag) with retry logic. diff --git a/web/static/css/gowebmail.css b/web/static/css/gowebmail.css index 858358f..ee24a10 100644 --- a/web/static/css/gowebmail.css +++ b/web/static/css/gowebmail.css @@ -349,9 +349,9 @@ body.app-page{overflow:hidden} .detail-from strong{color:var(--text)} .detail-from span{color:var(--muted);font-size:12px} .detail-date{font-size:12px;color:var(--muted);flex-shrink:0} -.detail-actions{padding:6px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0} -.action-btn{padding:4px 10px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px; - color:var(--text2);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;transition:background .15s} +.detail-actions{padding:6px 20px;border-bottom:1px solid var(--border);display:flex;gap:5px;flex-shrink:0} +.action-btn{padding:3px 8px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px; + color:var(--text2);font-family:'DM Sans',sans-serif;font-size:11px;cursor:pointer;transition:background .15s} .action-btn:hover{background:var(--surface3);color:var(--text)} .action-btn.danger:hover{background:rgba(239,68,68,.1);color:var(--danger);border-color:rgba(239,68,68,.3)} .detail-body{flex:1;overflow-y:auto;padding:16px 20px} @@ -474,6 +474,10 @@ body.admin-page{overflow:auto;background:var(--bg)} .fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s} .fmt-btn:hover{background:var(--border2);color:var(--text)} .fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px} +.fmt-font-select{background:var(--surface2);border:1px solid var(--border2);border-radius:4px;color:var(--text); + font-size:12px;padding:3px 4px;cursor:pointer;max-width:130px;color-scheme:dark} +.fmt-font-select:hover{border-color:var(--accent)} +.fmt-font-select option{background:var(--surface2);color:var(--text)} .compose-editor{flex:1;overflow-y:auto;padding:10px 12px; font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg);min-height:0} .compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none} @@ -519,6 +523,9 @@ body.admin-page{overflow:auto;background:var(--bg)} padding:4px 6px;min-height:32px;cursor:text;background:transparent} .tag-container:focus-within{} .compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:7px} +.compose-cc-bcc-toggle{display:flex;gap:8px;flex-shrink:0;align-self:flex-start;padding-top:7px} +.cc-bcc-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px;padding:0;transition:color .15s} +.cc-bcc-btn:hover{color:var(--accent)} .email-tag{display:inline-flex;align-items:center;gap:3px;padding:2px 6px 2px 8px; background:var(--surface3);border:1px solid var(--border2);border-radius:12px; font-size:12px;color:var(--text);white-space:nowrap;max-width:260px} @@ -686,8 +693,21 @@ body.admin-page{overflow:auto;background:var(--bg)} /* Desktop-only sidebar collapse control — mobile already has the drawer/hamburger */ .sidebar-collapse-btn{display:none} - /* Message list panel: full width, shown/hidden by data-mob-view */ - .message-list-panel{width:100%;border-right:none;flex-shrink:0} + /* Message action row (Reply/Forward/Star/...): wrap instead of overflowing + horizontally — there's no room for 9+ buttons in one row on a phone. */ + .detail-actions{flex-wrap:wrap} + + /* Let the whole message view (subject/from/buttons + body) scroll together as one + on a phone, instead of pinning the header/action row in place and squeezing the + body into whatever vertical space is left over — that left almost no room to read. */ + .message-detail{overflow-y:auto} + .detail-body{flex:none;overflow-y:visible} + + /* Message list panel: full width/height, shown/hidden by data-mob-view. !important + because the desktop drag-resize / reading-pane-bottom feature can leave an inline + height (e.g. "35%") on this element from a persisted desktop layout — without it, + the list would only fill that leftover fraction of the screen on mobile. */ + .message-list-panel{width:100%!important;height:100%!important;border-right:none;flex-shrink:0} .message-detail{width:100%} /* View switching via data-mob-view on #app-root */ @@ -705,6 +725,35 @@ body.admin-page{overflow:auto;background:var(--bg)} } /* Hide floating minimised bar on mobile, use back button instead */ .compose-minimised{display:none!important} + + /* Any other modal (add/edit account, login history, spam block, etc.): fit the screen + instead of overflowing a fixed desktop width. */ + .modal{width:calc(100vw - 24px)!important;max-width:440px} + + /* Settings modal on mobile becomes a full-screen "page" — it must render above the + app's own fixed .mob-topbar (z-index:200), otherwise the topbar sits on top of the + modal's own close button and swallows the tap. Modals opened from inside Settings + (add/edit account, etc.) keep stacking above it, just shifted up to match. */ + #settings-modal{z-index:220} + #add-account-modal,#edit-account-modal,#login-history-modal,#spam-block-modal{z-index:230} + + /* Settings modal: full screen, side nav becomes a horizontal sliding tab strip on top. */ + .settings-modal-box{ + width:100vw!important;max-width:100vw!important;height:100dvh!important;height:100vh!important; + max-height:100vh!important;border-radius:0!important; + position:fixed!important;inset:0!important; + } + .settings-body{flex-direction:column!important} + .settings-nav{ + width:100%!important;flex-direction:row!important;flex-shrink:0; + overflow-x:auto;-webkit-overflow-scrolling:touch; + border-right:none!important;border-bottom:1px solid var(--border); + padding:8px 10px!important;gap:4px!important; + } + .settings-nav button{ + width:auto!important;display:inline-block;white-space:nowrap;flex-shrink:0; + padding:6px 12px;font-size:12px; + } } /* ── Contacts ──────────────────────────────────────────────────────────── */ diff --git a/web/static/icons/icon-192.png b/web/static/icons/icon-192.png new file mode 100644 index 0000000..1594fde Binary files /dev/null and b/web/static/icons/icon-192.png differ diff --git a/web/static/icons/icon-512-maskable.png b/web/static/icons/icon-512-maskable.png new file mode 100644 index 0000000..d6f8b9f Binary files /dev/null and b/web/static/icons/icon-512-maskable.png differ diff --git a/web/static/icons/icon-512.png b/web/static/icons/icon-512.png new file mode 100644 index 0000000..c6b04ad Binary files /dev/null and b/web/static/icons/icon-512.png differ diff --git a/web/static/js/app.js b/web/static/js/app.js index 31a5397..11e1523 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -1786,7 +1786,6 @@ function renderMessageDetail(msg, showRemoteContent) { ${threadBtnHtml} - @@ -2031,6 +2030,8 @@ function openCompose(opts={}) { document.getElementById('compose-subject').value=opts.subject||''; document.getElementById('cc-row').style.display='none'; document.getElementById('bcc-row').style.display='none'; + document.getElementById('cc-toggle-btn').style.display=''; + document.getElementById('bcc-toggle-btn').style.display=''; populateComposeFrom(opts.accountId||null); const editor=document.getElementById('compose-editor'); const fromAccountId=parseInt(document.getElementById('compose-from')?.value||0); @@ -2092,8 +2093,8 @@ function _closeCompose() { S.composeVisible=false; S.composeMinimised=false; S.draftDirty=false; } -function showCCRow() { document.getElementById('cc-row').style.display='flex'; } -function showBCCRow() { document.getElementById('bcc-row').style.display='flex'; } +function showCCRow() { document.getElementById('cc-row').style.display='flex'; document.getElementById('cc-toggle-btn').style.display='none'; } +function showBCCRow() { document.getElementById('bcc-row').style.display='flex'; document.getElementById('bcc-toggle-btn').style.display='none'; } function openReply() { if (S.currentMessage) openReplyTo(S.currentMessage.id); } @@ -2302,6 +2303,22 @@ async function discardDraft() { // ── Compose formatting ───────────────────────────────────────────────────── function execFmt(cmd,val) { document.getElementById('compose-editor').focus(); document.execCommand(cmd,false,val||null); } + +// Opening the native -
+
+
+ + +
+