mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-13 23:30:37 +01:00
notifications and update layout for small screen
This commit is contained in:
+19
-1
@@ -10,4 +10,22 @@ webmail.code-workspace
|
||||
|
||||
graphify-out
|
||||
GEMINI.md
|
||||
tests/
|
||||
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/
|
||||
@@ -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
|
||||
|
||||
+35
-2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+62
-3
@@ -1786,7 +1786,6 @@ function renderMessageDetail(msg, showRemoteContent) {
|
||||
<button class="action-btn" onclick="openForwardAsAttachment()" title="Forward the original message as an .eml file attachment">↪ Fwd as Attachment</button>
|
||||
${threadBtnHtml}
|
||||
<button class="action-btn" onclick="toggleStar(${msg.id})">${msg.is_starred?'★ Unstar':'☆ Star'}</button>
|
||||
<button class="action-btn" onclick="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
|
||||
<button class="action-btn" onclick="${S.currentFolder==='snoozed'?'unsnoozeMessage':'snoozeMessage'}(${msg.id})">⏰ ${S.currentFolder==='snoozed'?'Unsnooze':'Snooze'}</button>
|
||||
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
|
||||
<button class="action-btn" onclick="downloadEML(${msg.id})">⬇ Download</button>
|
||||
@@ -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 <select> dropdown steals focus and clears the editor's text
|
||||
// selection before onchange fires, so fontName silently no-ops — save the range on
|
||||
// mousedown (before the dropdown opens) and restore it before applying the font.
|
||||
let savedComposeRange = null;
|
||||
function saveEditorRange() {
|
||||
const sel = window.getSelection();
|
||||
savedComposeRange = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
|
||||
}
|
||||
function applyFontFromSelect(select) {
|
||||
const editor = document.getElementById('compose-editor');
|
||||
const sel = window.getSelection();
|
||||
editor.focus();
|
||||
if (savedComposeRange) { sel.removeAllRanges(); sel.addRange(savedComposeRange); }
|
||||
if (select.value) document.execCommand('fontName', false, select.value);
|
||||
}
|
||||
function triggerAttach() { document.getElementById('compose-attach-input').click(); }
|
||||
function handleAttachFiles(input) { for(const file of input.files) composeAttachments.push({file,name:file.name,size:file.size}); input.value=''; updateAttachList(); S.draftDirty=true; }
|
||||
function removeAttachment(i) {
|
||||
@@ -3022,6 +3039,7 @@ async function toggleNotifications(enabled) {
|
||||
uiPrefsSet('notificationsEnabled', false);
|
||||
POLLER.notifGranted = false;
|
||||
updateNotificationStatus();
|
||||
await unsubscribePush();
|
||||
return;
|
||||
}
|
||||
if (!('Notification' in window)) {
|
||||
@@ -3040,6 +3058,7 @@ async function toggleNotifications(enabled) {
|
||||
uiPrefsSet('notificationsEnabled', true);
|
||||
POLLER.notifGranted = true;
|
||||
toast('Notifications enabled', 'success');
|
||||
await subscribePush(); // background push while the app isn't open/focused
|
||||
} else {
|
||||
if (cb) cb.checked = false;
|
||||
uiPrefsSet('notificationsEnabled', false);
|
||||
@@ -3048,6 +3067,46 @@ async function toggleNotifications(enabled) {
|
||||
updateNotificationStatus();
|
||||
}
|
||||
|
||||
// ---- Web Push subscription (background delivery — the in-page POLLER only covers
|
||||
// foreground/open-tab delivery) ----
|
||||
function urlBase64ToUint8Array(base64) {
|
||||
const padding = '='.repeat((4 - base64.length % 4) % 4);
|
||||
const raw = atob((base64 + padding).replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
async function subscribePush() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
const { public_key } = await api('GET', '/push/vapid-public-key') || {};
|
||||
if (!public_key) return;
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||||
});
|
||||
}
|
||||
await api('POST', '/push/subscribe', sub.toJSON());
|
||||
} catch (e) {
|
||||
console.error('Push subscribe failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribePush() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) return;
|
||||
await api('POST', '/push/unsubscribe', { endpoint: sub.endpoint });
|
||||
await sub.unsubscribe();
|
||||
} catch (e) {
|
||||
console.error('Push unsubscribe failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemoteWhitelist() {
|
||||
const el = document.getElementById('remote-whitelist-list');
|
||||
const r = await api('GET', '/remote-content-whitelist');
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// GoWebMail shared utilities - loaded on every page
|
||||
|
||||
// ---- Service worker (Web Push delivery) ----
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
// ---- API helper ----
|
||||
async function api(method, path, body, timeoutMs) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "GoWebMail",
|
||||
"short_name": "GoWebMail",
|
||||
"description": "Multi-account webmail client",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d0f14",
|
||||
"theme_color": "#0d0f14",
|
||||
"icons": [
|
||||
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/static/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// GoWebMail service worker — background Web Push delivery only.
|
||||
// No fetch/cache handling: this app is always online-driven, so an offline shell would
|
||||
// just serve stale mail; we deliberately don't add one.
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let data = {};
|
||||
try { data = event.data ? event.data.json() : {}; } catch (e) { /* non-JSON push, ignore */ }
|
||||
|
||||
const title = data.title || 'GoWebMail';
|
||||
const body = data.body || 'New mail';
|
||||
const tag = data.tag || 'gowebmail-new';
|
||||
|
||||
event.waitUntil((async () => {
|
||||
// Skip if a GoWebMail window is already open and focused — the in-page POLLER toast
|
||||
// already covers that case, so this avoids a duplicate notification.
|
||||
const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
if (clientList.some(c => c.focused)) return;
|
||||
|
||||
return self.registration.showNotification(title, {
|
||||
body,
|
||||
tag,
|
||||
icon: '/static/icons/icon-192.png',
|
||||
badge: '/static/icons/icon-192.png',
|
||||
data: { url: '/' },
|
||||
});
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = (event.notification.data && event.notification.data.url) || '/';
|
||||
event.waitUntil((async () => {
|
||||
const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
for (const c of clientList) {
|
||||
if ('focus' in c) return c.focus();
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(url);
|
||||
})());
|
||||
});
|
||||
+27
-6
@@ -281,11 +281,33 @@
|
||||
</div>
|
||||
<div class="compose-body-wrap" id="compose-body-wrap">
|
||||
<div class="compose-field"><label for="compose-from">From</label><select id="compose-from" onchange="onComposeFromChange()"></select></div>
|
||||
<div class="compose-field compose-tag-field"><label id="compose-to-label">To</label><div id="compose-to" class="tag-container" role="group" aria-labelledby="compose-to-label"></div></div>
|
||||
<div class="compose-field compose-tag-field"><label id="compose-to-label">To</label><div id="compose-to" class="tag-container" role="group" aria-labelledby="compose-to-label"></div>
|
||||
<div class="compose-cc-bcc-toggle">
|
||||
<button type="button" class="cc-bcc-btn" id="cc-toggle-btn" onclick="showCCRow()">Cc</button>
|
||||
<button type="button" class="cc-bcc-btn" id="bcc-toggle-btn" onclick="showBCCRow()">Bcc</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label id="compose-cc-label">CC</label><div id="compose-cc-tags" class="tag-container" role="group" aria-labelledby="compose-cc-label"></div></div>
|
||||
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label id="compose-bcc-label">BCC</label><div id="compose-bcc-tags" class="tag-container" role="group" aria-labelledby="compose-bcc-label"></div></div>
|
||||
<div class="compose-field"><label for="compose-subject">Subject</label><input type="text" id="compose-subject" oninput="S.draftDirty=true"></div>
|
||||
<div class="compose-toolbar" role="toolbar" aria-label="Formatting">
|
||||
<select class="fmt-font-select" title="Font" aria-label="Font family" onmousedown="saveEditorRange()" onchange="applyFontFromSelect(this)">
|
||||
<option value="">Font</option>
|
||||
<option value="Arial" style="font-family:Arial">Arial</option>
|
||||
<option value="Helvetica" style="font-family:Helvetica">Helvetica</option>
|
||||
<option value="Georgia" style="font-family:Georgia">Georgia</option>
|
||||
<option value="'Times New Roman'" style="font-family:'Times New Roman'">Times New Roman</option>
|
||||
<option value="'Courier New'" style="font-family:'Courier New'">Courier New</option>
|
||||
<option value="Verdana" style="font-family:Verdana">Verdana</option>
|
||||
<option value="Tahoma" style="font-family:Tahoma">Tahoma</option>
|
||||
<option value="'Trebuchet MS'" style="font-family:'Trebuchet MS'">Trebuchet MS</option>
|
||||
<option value="Garamond" style="font-family:Garamond">Garamond</option>
|
||||
<option value="'Palatino Linotype'" style="font-family:'Palatino Linotype'">Palatino</option>
|
||||
<option value="'Comic Sans MS'" style="font-family:'Comic Sans MS'">Comic Sans MS</option>
|
||||
<option value="Impact" style="font-family:Impact">Impact</option>
|
||||
<option value="'Lucida Console'" style="font-family:'Lucida Console'">Lucida Console</option>
|
||||
</select>
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Bold" aria-label="Bold" onclick="execFmt('bold')"><b>B</b></button>
|
||||
<button class="fmt-btn" title="Italic" aria-label="Italic" onclick="execFmt('italic')"><i>I</i></button>
|
||||
<button class="fmt-btn" title="Underline" aria-label="Underline" onclick="execFmt('underline')"><u>U</u></button>
|
||||
@@ -295,15 +317,14 @@
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Link" aria-label="Insert link" onclick="insertLink()">🔗</button>
|
||||
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T⃗</button>
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Attach files" aria-label="Attach files" onclick="triggerAttach()">📎</button>
|
||||
</div>
|
||||
<div id="compose-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Message body" class="compose-editor" placeholder="Write your message..."></div>
|
||||
<div id="compose-attach-list" class="compose-attach-list"></div>
|
||||
<div class="compose-footer">
|
||||
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
|
||||
<div style="display:flex;gap:6px;margin-left:4px">
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="showCCRow()">+CC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="showBCCRow()">+BCC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">📎 Attach</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">✎ Draft</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="openSendLater()">🕓 Send later</button>
|
||||
</div>
|
||||
@@ -607,12 +628,12 @@
|
||||
|
||||
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="settings-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="settings-modal-title">
|
||||
<div class="modal" style="width:820px;max-width:95vw;height:640px;max-height:90vh;padding:0;display:flex;flex-direction:column">
|
||||
<div class="modal settings-modal-box" style="width:820px;max-width:95vw;height:640px;max-height:90vh;padding:0;display:flex;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:22px 24px 16px">
|
||||
<h2 id="settings-modal-title" style="margin-bottom:0">Settings</h2>
|
||||
<button onclick="closeModal('settings-modal')" class="icon-btn" aria-label="Close settings"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
|
||||
<div class="settings-body" style="display:flex;align-items:stretch;min-height:0;flex:1;border-top:1px solid var(--border)">
|
||||
<div class="settings-nav" role="tablist" aria-label="Settings sections">
|
||||
<button data-tab="accounts" class="active" role="tab" aria-selected="true" onclick="showSettingsTab('accounts')">Accounts</button>
|
||||
<button data-tab="general" role="tab" aria-selected="false" onclick="showSettingsTab('general')">General</button>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<title>{{block "title" .}}GoWebMail{{end}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=79">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0d0f14">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
|
||||
@@ -4,5 +4,5 @@ import "embed"
|
||||
|
||||
// Global access to the web assets
|
||||
//
|
||||
//go:embed web/static/css/* web/static/js/* web/static/img/* web/templates/**
|
||||
//go:embed web/static/css/* web/static/js/* web/static/img/* web/static/icons/* web/static/manifest.json web/static/sw.js web/templates/**
|
||||
var WebFS embed.FS
|
||||
|
||||
Reference in New Issue
Block a user