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
+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.