mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-09-15 00:00:36 +01:00
notifications and update layout for small screen
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user