update name, project refference and synchronization

This commit is contained in:
ghostersk
2026-03-08 06:06:38 +00:00
parent 5d51b9778b
commit b29949e042
27 changed files with 587 additions and 137 deletions
+105 -7
View File
@@ -7,8 +7,8 @@ import (
"strings"
"time"
"github.com/yourusername/gomail/internal/crypto"
"github.com/yourusername/gomail/internal/models"
"github.com/ghostersk/gowebmail/internal/crypto"
"github.com/ghostersk/gowebmail/internal/models"
_ "github.com/mattn/go-sqlite3"
)
@@ -526,7 +526,6 @@ func (d *DB) ListAuditLogs(page, pageSize int, eventFilter string) (*models.Audi
}, rows.Err()
}
// ---- Email Accounts ----
func (d *DB) CreateAccount(a *models.EmailAccount) error {
@@ -797,7 +796,8 @@ func (d *DB) GetFolderByPath(accountID int64, fullPath string) (*models.Folder,
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE account_id=? AND full_path=?`, accountID, fullPath,
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled)
f.IsHidden = isHidden == 1; f.SyncEnabled = syncEnabled == 1
f.IsHidden = isHidden == 1
f.SyncEnabled = syncEnabled == 1
if err == sql.ErrNoRows {
return nil, nil
}
@@ -1161,8 +1161,12 @@ func (d *DB) IsRemoteContentAllowed(userID int64, sender string) (bool, error) {
// SetFolderVisibility sets is_hidden and sync_enabled for a folder owned by the user.
func (d *DB) SetFolderVisibility(folderID, userID int64, isHidden, syncEnabled bool) error {
ih, se := 0, 0
if isHidden { ih = 1 }
if syncEnabled { se = 1 }
if isHidden {
ih = 1
}
if syncEnabled {
se = 1
}
_, err := d.sql.Exec(`
UPDATE folders SET is_hidden=?, sync_enabled=?
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
@@ -1212,7 +1216,8 @@ func (d *DB) GetFolderByID(folderID int64) (*models.Folder, error) {
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE id=?`, folderID,
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled)
f.IsHidden = isHidden == 1; f.SyncEnabled = syncEnabled == 1
f.IsHidden = isHidden == 1
f.SyncEnabled = syncEnabled == 1
if err == sql.ErrNoRows {
return nil, nil
}
@@ -1465,3 +1470,96 @@ func boolToInt(b bool) int {
}
return 0
}
// EmptyFolder deletes all messages in a folder (Trash/Spam).
// Returns count deleted.
func (d *DB) EmptyFolder(folderID, userID int64) (int, error) {
res, err := d.sql.Exec(`
DELETE FROM messages WHERE folder_id=?
AND folder_id IN (SELECT id FROM folders WHERE account_id IN
(SELECT id FROM email_accounts WHERE user_id=?))`,
folderID, userID,
)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// EnableAllFolderSync enables sync for all currently-disabled folders belonging
// to accounts owned by userID. Returns count updated.
func (d *DB) EnableAllFolderSync(accountID, userID int64) (int, error) {
res, err := d.sql.Exec(`
UPDATE folders SET sync_enabled=1
WHERE account_id=? AND sync_enabled=0
AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
accountID, userID,
)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
// PollUnread returns inbox unread count + total unread, and whether there are
// new messages since `sinceID`. Used by the client-side poller.
func (d *DB) PollUnread(userID int64, sinceID int64) (inboxUnread int, totalUnread int, newestID int64, err error) {
// Inbox unread count
d.sql.QueryRow(`
SELECT COALESCE(SUM(f.unread_count),0) FROM folders f
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=? AND f.folder_type='inbox'`, userID,
).Scan(&inboxUnread)
// Total unread (all folders except trash/spam)
d.sql.QueryRow(`
SELECT COALESCE(SUM(f.unread_count),0) FROM folders f
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=? AND f.folder_type NOT IN ('trash','spam')`, userID,
).Scan(&totalUnread)
// Newest message ID in inbox
d.sql.QueryRow(`
SELECT COALESCE(MAX(m.id),0) FROM messages m
JOIN folders f ON f.id=m.folder_id
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=? AND f.folder_type='inbox'`, userID,
).Scan(&newestID)
return
}
// GetNewMessagesSince returns inbox message summaries with id > sinceID for notifications.
func (d *DB) GetNewMessagesSince(userID int64, sinceID int64) ([]map[string]interface{}, error) {
rows, err := d.sql.Query(`
SELECT m.id, m.subject, m.from_name, m.from_email
FROM messages m
JOIN folders f ON f.id=m.folder_id
JOIN email_accounts a ON a.id=f.account_id
WHERE a.user_id=? AND f.folder_type='inbox' AND m.id>?
ORDER BY m.id DESC LIMIT 5`, userID, sinceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var result []map[string]interface{}
for rows.Next() {
var id int64
var subject, fromName, fromEmail string
rows.Scan(&id, &subject, &fromName, &fromEmail)
// Decrypt
subject, _ = d.enc.Decrypt(subject)
fromName, _ = d.enc.Decrypt(fromName)
fromEmail, _ = d.enc.Decrypt(fromEmail)
result = append(result, map[string]interface{}{
"id": id, "subject": subject, "from_name": fromName, "from_email": fromEmail,
})
}
if result == nil {
result = []map[string]interface{}{}
}
return result, rows.Err()
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
gomailModels "github.com/yourusername/gomail/internal/models"
gomailModels "github.com/ghostersk/gowebmail/internal/models"
)
func imapHostFor(provider gomailModels.AccountProvider) (string, int) {
+15 -15
View File
@@ -6,11 +6,11 @@ import (
"strconv"
"strings"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/gorilla/mux"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/middleware"
"github.com/yourusername/gomail/internal/models"
)
// AdminHandler handles /admin/* routes (all require admin role).
@@ -46,14 +46,14 @@ func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
}
// Sanitize: strip password hash
type safeUser struct {
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
Role models.UserRole `json:"role"`
IsActive bool `json:"is_active"`
MFAEnabled bool `json:"mfa_enabled"`
LastLoginAt interface{} `json:"last_login_at"`
CreatedAt interface{} `json:"created_at"`
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
Role models.UserRole `json:"role"`
IsActive bool `json:"is_active"`
MFAEnabled bool `json:"mfa_enabled"`
LastLoginAt interface{} `json:"last_login_at"`
CreatedAt interface{} `json:"created_at"`
}
result := make([]safeUser, 0, len(users))
for _, u := range users {
@@ -108,9 +108,9 @@ func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
var req struct {
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
IsActive *bool `json:"is_active"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
+123 -28
View File
@@ -11,20 +11,20 @@ import (
"strings"
"time"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/email"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/syncer"
"github.com/gorilla/mux"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/email"
"github.com/yourusername/gomail/internal/middleware"
"github.com/yourusername/gomail/internal/models"
"github.com/yourusername/gomail/internal/syncer"
)
// APIHandler handles all /api/* JSON endpoints.
type APIHandler struct {
db *db.DB
cfg *config.Config
syncer *syncer.Scheduler
db *db.DB
cfg *config.Config
syncer *syncer.Scheduler
}
func (h *APIHandler) writeJSON(w http.ResponseWriter, v interface{}) {
@@ -93,13 +93,13 @@ func (h *APIHandler) ListAccounts(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request body")
@@ -128,8 +128,8 @@ func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) {
account := &models.EmailAccount{
UserID: userID, Provider: models.ProviderIMAPSMTP,
EmailAddress: req.Email, DisplayName: req.DisplayName,
AccessToken: req.Password,
IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort,
AccessToken: req.Password,
IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort,
SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort,
Color: color, IsActive: true,
}
@@ -170,12 +170,12 @@ func (h *APIHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) {
}
var req struct {
DisplayName string `json:"display_name"`
Password string `json:"password"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request")
@@ -538,7 +538,9 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
var req struct{ Read bool `json:"read"` }
var req struct {
Read bool `json:"read"`
}
json.NewDecoder(r.Body).Decode(&req)
// Update local DB first
@@ -548,7 +550,9 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
if err == nil && uid != 0 && account != nil {
val := "0"
if req.Read { val = "1" }
if req.Read {
val = "1"
}
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "flag_read",
RemoteUID: uid, FolderPath: folderPath, Extra: val,
@@ -569,7 +573,9 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
if ierr == nil && uid != 0 && account != nil {
val := "0"
if starred { val = "1" }
if starred {
val = "1"
}
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "flag_star",
RemoteUID: uid, FolderPath: folderPath, Extra: val,
@@ -582,7 +588,9 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id")
var req struct{ FolderID int64 `json:"folder_id"` }
var req struct {
FolderID int64 `json:"folder_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.FolderID == 0 {
h.writeError(w, http.StatusBadRequest, "folder_id required")
return
@@ -967,3 +975,90 @@ func (h *APIHandler) AddRemoteContentWhitelist(w http.ResponseWriter, r *http.Re
}
h.writeJSON(w, map[string]bool{"ok": true})
}
// ---- Empty folder (Trash/Spam) ----
func (h *APIHandler) EmptyFolder(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
folderID := pathInt64(r, "id")
// Verify folder is trash or spam before allowing bulk delete
folder, err := h.db.GetFolderByID(folderID)
if err != nil || folder == nil {
h.writeError(w, http.StatusNotFound, "folder not found")
return
}
if folder.FolderType != "trash" && folder.FolderType != "spam" {
h.writeError(w, http.StatusBadRequest, "can only empty trash or spam folders")
return
}
n, err := h.db.EmptyFolder(folderID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to empty folder")
return
}
h.db.UpdateFolderCounts(folderID)
h.writeJSON(w, map[string]interface{}{"ok": true, "deleted": n})
}
// ---- Enable sync for all folders of an account ----
func (h *APIHandler) EnableAllFolderSync(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
vars := mux.Vars(r)
accountIDStr := vars["account_id"]
var accountID int64
fmt.Sscanf(accountIDStr, "%d", &accountID)
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
return
}
n, err := h.db.EnableAllFolderSync(accountID, userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to enable sync")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true, "enabled": n})
}
// ---- Long-poll for unread counts + new message detection ----
// GET /api/poll?since=<lastKnownMessageID>
// Returns immediately with current counts; client polls every ~20s.
func (h *APIHandler) PollUnread(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var sinceID int64
fmt.Sscanf(r.URL.Query().Get("since"), "%d", &sinceID)
inboxUnread, totalUnread, newestID, err := h.db.PollUnread(userID, sinceID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "poll failed")
return
}
h.writeJSON(w, map[string]interface{}{
"inbox_unread": inboxUnread,
"total_unread": totalUnread,
"newest_id": newestID,
"has_new": newestID > sinceID && sinceID > 0,
})
}
// ---- Get new messages since ID (for notification content) ----
func (h *APIHandler) NewMessagesSince(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var sinceID int64
fmt.Sscanf(r.URL.Query().Get("since"), "%d", &sinceID)
if sinceID == 0 {
h.writeJSON(w, map[string]interface{}{"messages": []interface{}{}})
return
}
msgs, err := h.db.GetNewMessagesSince(userID, sinceID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "query failed")
return
}
h.writeJSON(w, map[string]interface{}{"messages": msgs})
}
+2 -2
View File
@@ -3,8 +3,8 @@ package handlers
import (
"net/http"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
)
// AppHandler serves the main app pages using the shared Renderer.
+13 -9
View File
@@ -7,13 +7,13 @@ import (
"net/http"
"time"
"github.com/yourusername/gomail/config"
goauth "github.com/yourusername/gomail/internal/auth"
"github.com/yourusername/gomail/internal/crypto"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/mfa"
"github.com/yourusername/gomail/internal/middleware"
"github.com/yourusername/gomail/internal/models"
"github.com/ghostersk/gowebmail/config"
goauth "github.com/ghostersk/gowebmail/internal/auth"
"github.com/ghostersk/gowebmail/internal/crypto"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/mfa"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"golang.org/x/oauth2"
)
@@ -155,7 +155,9 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct{ Code string `json:"code"` }
var req struct {
Code string `json:"code"`
}
json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID)
@@ -181,7 +183,9 @@ func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandler) MFADisable(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
var req struct{ Code string `json:"code"` }
var req struct {
Code string `json:"code"`
}
json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID)
+3 -3
View File
@@ -3,9 +3,9 @@ package handlers
import (
"log"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/syncer"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/syncer"
)
type Handlers struct {
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strings"
"time"
"github.com/yourusername/gomail/config"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/models"
"github.com/ghostersk/gowebmail/config"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/models"
)
type contextKey string
+5 -7
View File
@@ -12,9 +12,9 @@ import (
"sync"
"time"
"github.com/yourusername/gomail/internal/db"
"github.com/yourusername/gomail/internal/email"
"github.com/yourusername/gomail/internal/models"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/email"
"github.com/ghostersk/gowebmail/internal/models"
)
// Scheduler coordinates all background sync activity.
@@ -24,8 +24,8 @@ type Scheduler struct {
wg sync.WaitGroup
// push channels: accountID -> channel to signal "something changed on server"
pushMu sync.Mutex
pushCh map[int64]chan struct{}
pushMu sync.Mutex
pushCh map[int64]chan struct{}
}
// New creates a new Scheduler.
@@ -570,5 +570,3 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
return s.syncFolder(c, account, folder)
}