notifications and update layout for small screen

This commit is contained in:
2026-08-30 19:10:04 +01:00
parent 45ce8e4e24
commit 1552da11ab
19 changed files with 567 additions and 18 deletions
+19 -1
View File
@@ -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/
+25
View File
@@ -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
View File
@@ -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 {
+2
View File
@@ -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
+66
View File
@@ -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=
+82
View File
@@ -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()
}
+60
View File
@@ -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})
}
+69
View File
@@ -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.
+54 -5
View File
@@ -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
View File
@@ -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');
+5
View File
@@ -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' } };
+15
View File
@@ -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" }
]
}
+42
View File
@@ -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
View File
@@ -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()">&#128279;</button>
<button class="fmt-btn" title="Clear format" aria-label="Clear formatting" onclick="execFmt('removeFormat')">T&#x20D7;</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" title="Attach files" aria-label="Attach files" onclick="triggerAttach()">&#128206;</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()">&#128206; Attach</button>
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">&#9998; Draft</button>
<button class="btn-secondary" style="font-size:12px" onclick="openSendLater()">&#128339; 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>
+3
View File
@@ -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}}">
+1 -1
View File
@@ -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