From b29949e042b9152af6cf5afebcec47c353f09353 Mon Sep 17 00:00:00 2001 From: ghostersk Date: Sun, 8 Mar 2026 06:06:38 +0000 Subject: [PATCH] update name, project refference and synchronization --- .gitignore | 2 +- Dockerfile | 14 +- README.md | 4 +- cmd/server/main.go | 45 ++-- config/config.go | 12 +- ...il.conf.example => gowebmail.conf.example} | 2 +- docker-compose.yml | 6 +- go.mod | 2 +- internal/db/db.go | 112 ++++++++- internal/email/imap.go | 2 +- internal/handlers/admin.go | 30 +-- internal/handlers/api.go | 151 +++++++++--- internal/handlers/app.go | 4 +- internal/handlers/auth.go | 22 +- internal/handlers/handlers.go | 6 +- internal/middleware/middleware.go | 6 +- internal/syncer/syncer.go | 12 +- web/static/css/{gomail.css => gowebmail.css} | 31 +++ web/static/img/favicon.png | Bin 0 -> 17553 bytes web/static/js/admin.js | 2 +- web/static/js/app.js | 228 +++++++++++++++++- web/static/js/{gomail.js => gowebmail.js} | 0 web/templates/admin.html | 4 +- web/templates/app.html | 10 +- web/templates/base.html | 6 +- web/templates/login.html | 7 +- web/templates/mfa.html | 4 +- 27 files changed, 587 insertions(+), 137 deletions(-) rename data/{gomail.conf.example => gowebmail.conf.example} (99%) rename web/static/css/{gomail.css => gowebmail.css} (95%) create mode 100644 web/static/img/favicon.png rename web/static/js/{gomail.js => gowebmail.js} (100%) diff --git a/.gitignore b/.gitignore index 7ae840b..cad5a69 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ data/envs data/*.db data/*.db-shm data/*db-wal -data/gomail.conf +data/gowebmail.conf data/*.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d1d90e2..4ea18b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,24 +7,24 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gomail ./cmd/server +RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server # ---- Runtime ---- FROM alpine:3.19 RUN apk add --no-cache sqlite-libs ca-certificates tzdata WORKDIR /app -COPY --from=builder /app/gomail . +COPY --from=builder /app/gowebmail . COPY --from=builder /app/web ./web -RUN mkdir -p /data && addgroup -S gomail && adduser -S gomail -G gomail -RUN chown -R gomail:gomail /app /data -USER gomail +RUN mkdir -p /data && addgroup -S gowebmail && adduser -S gowebmail -G gowebmail +RUN chown -R gowebmail:gowebmail /app /data +USER gowebmail VOLUME ["/data"] EXPOSE 8080 -ENV DB_PATH=/data/gomail.db +ENV DB_PATH=/data/gowebmail.db ENV LISTEN_ADDR=:8080 -CMD ["./gomail"] +CMD ["./gowebmail"] diff --git a/README.md b/README.md index b5e91f6..e2cdfb7 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Visit http://localhost:8080, default login admin/admin, register an account, the ```bash git clone https://github.com/ghostersk/gowebmail && cd gowebmail go run ./cmd/server/main.go -# check ./data/gomail.conf what gets generated on first run if not exists, update as needed. +# check ./data/gowebmail.conf what gets generated on first run if not exists, update as needed. # then restart the app ``` ### Reset Admin password, MFA @@ -115,7 +115,7 @@ golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints ## Building for Production ```bash -CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gomail ./cmd/server +CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gowebmail ./cmd/server ``` CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler. diff --git a/cmd/server/main.go b/cmd/server/main.go index 331446d..fbed24f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,11 +10,11 @@ import ( "syscall" "time" - "github.com/yourusername/gomail/config" - "github.com/yourusername/gomail/internal/db" - "github.com/yourusername/gomail/internal/handlers" - "github.com/yourusername/gomail/internal/middleware" - "github.com/yourusername/gomail/internal/syncer" + "github.com/ghostersk/gowebmail/config" + "github.com/ghostersk/gowebmail/internal/db" + "github.com/ghostersk/gowebmail/internal/handlers" + "github.com/ghostersk/gowebmail/internal/middleware" + "github.com/ghostersk/gowebmail/internal/syncer" "github.com/gorilla/mux" ) @@ -22,9 +22,9 @@ import ( func main() { // ── CLI admin commands (run without starting the HTTP server) ────────── // Usage: - // ./gomail --list-admin list all admin usernames - // ./gomail --pw reset an admin's password - // ./gomail --mfa-off disable MFA for an admin + // ./gowebmail --list-admin list all admin usernames + // ./gowebmail --pw reset an admin's password + // ./gowebmail --mfa-off disable MFA for an admin args := os.Args[1:] if len(args) > 0 { switch args[0] { @@ -33,14 +33,14 @@ func main() { return case "--pw": if len(args) < 3 { - fmt.Fprintln(os.Stderr, "Usage: gomail --pw \"\"") + fmt.Fprintln(os.Stderr, "Usage: gowebmail --pw \"\"") os.Exit(1) } runResetPassword(args[1], args[2]) return case "--mfa-off": if len(args) < 2 { - fmt.Fprintln(os.Stderr, "Usage: gomail --mfa-off ") + fmt.Fprintln(os.Stderr, "Usage: gowebmail --mfa-off ") os.Exit(1) } runDisableMFA(args[1]) @@ -83,7 +83,9 @@ func main() { r.PathPrefix("/static/").Handler( http.StripPrefix("/static/", http.FileServer(http.Dir("./web/static/"))), ) - + r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./web/static/img/favicon.png") + }) // Public auth routes auth := r.PathPrefix("/auth").Subrouter() auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET") @@ -172,7 +174,11 @@ func main() { api.HandleFunc("/folders/{id:[0-9]+}/visibility", h.API.SetFolderVisibility).Methods("PUT") api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET") api.HandleFunc("/folders/{id:[0-9]+}/move-to/{toId:[0-9]+}", h.API.MoveFolderContents).Methods("POST") + api.HandleFunc("/folders/{id:[0-9]+}/empty", h.API.EmptyFolder).Methods("POST") api.HandleFunc("/folders/{id:[0-9]+}", h.API.DeleteFolder).Methods("DELETE") + api.HandleFunc("/accounts/{account_id:[0-9]+}/enable-all-sync", h.API.EnableAllFolderSync).Methods("POST") + api.HandleFunc("/poll", h.API.PollUnread).Methods("GET") + api.HandleFunc("/new-messages", h.API.NewMessagesSince).Methods("GET") api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET") api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT") @@ -206,7 +212,7 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) go func() { - log.Printf("GoMail listening on %s", cfg.ListenAddr) + log.Printf("GoWebMail listening on %s", cfg.ListenAddr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server: %v", err) } @@ -289,19 +295,18 @@ func printHelp() { fmt.Print(`GoMail — Admin CLI Usage: - gomail Start the mail server - gomail --list-admin List all admin accounts (username, email, MFA status) - gomail --pw Reset password for an admin account - gomail --mfa-off Disable MFA for an admin account + gowebmail Start the mail server + gowebmail --list-admin List all admin accounts (username, email, MFA status) + gowebmail --pw Reset password for an admin account + gowebmail --mfa-off Disable MFA for an admin account Examples: - ./gomail --list-admin - ./gomail --pw admin "NewSecurePass123" - ./gomail --mfa-off admin + ./gowebmail --list-admin + ./gowebmail --pw admin "NewSecurePass123" + ./gowebmail --mfa-off admin Note: These commands only work on admin accounts. Regular user management is done through the web UI. Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc). `) } - diff --git a/config/config.go b/config/config.go index 8f248a2..3a9d83a 100644 --- a/config/config.go +++ b/config/config.go @@ -1,4 +1,4 @@ -// Package config loads and persists GoMail configuration from data/gomail.conf +// Package config loads and persists GoMail configuration from data/gowebmail.conf package config import ( @@ -43,7 +43,7 @@ type Config struct { MicrosoftRedirectURL string // auto-derived from BaseURL if blank } -const configPath = "./data/gomail.conf" +const configPath = "./data/gowebmail.conf" type configField struct { key string @@ -52,7 +52,7 @@ type configField struct { } // allFields is the single source of truth for config keys. -// Adding a field here causes it to automatically appear in gomail.conf on next startup. +// Adding a field here causes it to automatically appear in gowebmail.conf on next startup. var allFields = []configField{ { key: "HOSTNAME", @@ -120,7 +120,7 @@ var allFields = []configField{ }, { key: "DB_PATH", - defVal: "./data/gomail.db", + defVal: "./data/gowebmail.db", comments: []string{ "--- Storage ---", "Path to the SQLite database file.", @@ -200,7 +200,7 @@ var allFields = []configField{ }, } -// Load reads/creates data/gomail.conf, fills in missing keys, then returns Config. +// Load reads/creates data/gowebmail.conf, fills in missing keys, then returns Config. // Environment variables override file values when set. func Load() (*Config, error) { if err := os.MkdirAll("./data", 0700); err != nil { @@ -215,7 +215,7 @@ func Load() (*Config, error) { // Auto-generate secrets if missing if existing["ENCRYPTION_KEY"] == "" { existing["ENCRYPTION_KEY"] = mustHex(32) - fmt.Println("WARNING: Generated new ENCRYPTION_KEY — it is saved in data/gomail.conf — back it up!") + fmt.Println("WARNING: Generated new ENCRYPTION_KEY — it is saved in data/gowebmail.conf — back it up!") } if existing["SESSION_SECRET"] == "" { existing["SESSION_SECRET"] = mustHex(32) diff --git a/data/gomail.conf.example b/data/gowebmail.conf.example similarity index 99% rename from data/gomail.conf.example rename to data/gowebmail.conf.example index bfa8769..1480aa8 100644 --- a/data/gomail.conf.example +++ b/data/gowebmail.conf.example @@ -47,7 +47,7 @@ TRUSTED_PROXIES = # --- Storage --- # Path to the SQLite database file. -DB_PATH = ./data/gomail.db +DB_PATH = ./data/gowebmail.db # AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets). # Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run. diff --git a/docker-compose.yml b/docker-compose.yml index 6f09851..1df3c36 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,12 @@ version: '3.9' services: - gomail: + gowebmail: build: . ports: - "8080:8080" volumes: - - gomail-data:/data + - gowebmail-data:/data environment: # REQUIRED: Generate with: openssl rand -hex 32 ENCRYPTION_KEY: "" @@ -32,4 +32,4 @@ services: restart: unless-stopped volumes: - gomail-data: + gowebmail-data: diff --git a/go.mod b/go.mod index 64ef620..bfeafa2 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/yourusername/gomail +module github.com/ghostersk/gowebmail go 1.26 diff --git a/internal/db/db.go b/internal/db/db.go index 8ef9552..a8784af 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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() +} diff --git a/internal/email/imap.go b/internal/email/imap.go index ca5c0c3..8c50d49 100644 --- a/internal/email/imap.go +++ b/internal/email/imap.go @@ -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) { diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index 2ae9004..4482a42 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -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") diff --git a/internal/handlers/api.go b/internal/handlers/api.go index ed483c2..d92803d 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -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= +// 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}) +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 92083ae..e9bc889 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -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. diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 72c0595..7d67eb3 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 9cb2aaf..83a7c8b 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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 { diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index e8b4491..c89ff2c 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -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 diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 9e89010..5961b5f 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -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) } - - diff --git a/web/static/css/gomail.css b/web/static/css/gowebmail.css similarity index 95% rename from web/static/css/gomail.css rename to web/static/css/gowebmail.css index a7945fe..8a4e57b 100644 --- a/web/static/css/gomail.css +++ b/web/static/css/gowebmail.css @@ -145,6 +145,7 @@ body.app-page{overflow:hidden} border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden} .sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border); display:flex;align-items:center;justify-content:space-between} +.sidebar-header .logo a{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--text)} .logo{display:flex;align-items:center;gap:8px} .logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px; display:flex;align-items:center;justify-content:center;flex-shrink:0} @@ -464,3 +465,33 @@ body.admin-page{overflow:auto;background:var(--bg)} color:var(--text2);transition:background .1s;white-space:nowrap} .filter-opt:hover{background:var(--surface3);color:var(--text)} .filter-sep-line{height:1px;background:var(--border);margin:3px 0} + +/* ── New mail corner toast ───────────────────────────────────── */ +.newmail-toast{ + position:fixed;bottom:24px;right:24px;z-index:2000; + display:flex;align-items:flex-start;gap:10px; + background:var(--surface2);border:1px solid var(--border2); + border-left:3px solid var(--accent); + border-radius:10px;padding:12px 14px; + box-shadow:0 8px 32px rgba(0,0,0,.55); + max-width:320px;min-width:240px; + cursor:pointer; + animation:toastSlideIn .25s cubic-bezier(.2,.8,.4,1); + transition:opacity .2s,transform .2s; +} +.newmail-toast:hover{border-left-color:#7eb8f7;background:var(--surface3)} +.newmail-toast-icon{font-size:18px;flex-shrink:0;color:var(--accent);line-height:1.2} +.newmail-toast-body{flex:1;font-size:13px;line-height:1.4;color:var(--text); + min-width:0;word-break:break-word} +.newmail-toast-body strong{color:var(--text);display:block;margin-bottom:2px} +.newmail-toast-body span{color:var(--text2);font-size:12px; + display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.newmail-toast-close{ + background:none;border:none;color:var(--muted);cursor:pointer; + font-size:13px;padding:0 0 0 6px;line-height:1;flex-shrink:0;align-self:flex-start +} +.newmail-toast-close:hover{color:var(--text)} +@keyframes toastSlideIn{ + from{opacity:0;transform:translateY(16px) scale(.96)} + to{opacity:1;transform:translateY(0) scale(1)} +} diff --git a/web/static/img/favicon.png b/web/static/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..21040b29a9ff68d5a45f4d3b53317433641afea5 GIT binary patch literal 17553 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 literal 0 HcmV?d00001 diff --git a/web/static/js/admin.js b/web/static/js/admin.js index f75b336..c3170aa 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -209,7 +209,7 @@ async function renderSettings() { el.innerHTML = `

Application Settings

-

Changes are saved to data/gomail.conf and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.

+

Changes are saved to data/gowebmail.conf and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.

diff --git a/web/static/js/app.js b/web/static/js/app.js index fdc05a7..04faef0 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -31,6 +31,10 @@ async function init() { await loadAccounts(); await loadFolders(); await loadMessages(); + // Seed poller ID so we don't notify on initial load + if (S.messages.length > 0) { + POLLER.lastKnownID = Math.max(...S.messages.map(m=>m.id)); + } const p = new URLSearchParams(location.search); if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); } @@ -44,6 +48,7 @@ async function init() { }); initComposeDragResize(); + startPoller(); } // ── Providers ────────────────────────────────────────────────────────────── @@ -372,11 +377,19 @@ function showFolderMenu(e, folderId) {
${moveItems}
` : ''; + const isTrashOrSpam = f.folder_type==='trash' || f.folder_type==='spam'; + const emptyEntry = isTrashOrSpam + ? `
🗑 Empty ${f.name}
` : ''; + const disabledCount = S.folders.filter(x=>x.account_id===f.account_id&&!x.sync_enabled).length; + const enableAllEntry = disabledCount > 0 + ? `
↻ Enable sync for all folders (${disabledCount})
` : ''; showCtxMenu(e, `
↻ Sync this folder
${syncLabel}
+ ${enableAllEntry}
${moveEntry} + ${emptyEntry}
👁 Hide from sidebar
🗑 Delete folder
`); } @@ -400,6 +413,36 @@ async function toggleFolderSync(folderId) { } else toast('Update failed','error'); } +async function enableAllFolderSync(accountId) { + const r = await api('POST','/accounts/'+accountId+'/enable-all-sync'); + if (r?.ok) { + // Update local state + S.folders.forEach(f=>{ if(f.account_id===accountId) f.sync_enabled=true; }); + toast(`Sync enabled for ${r.enabled||0} folder${r.enabled===1?'':'s'}`, 'success'); + renderFolders(); + } else toast('Failed to enable sync', 'error'); +} + +async function confirmEmptyFolder(folderId) { + const f = S.folders.find(f=>f.id===folderId); + if (!f) return; + const label = f.folder_type==='trash' ? 'Trash' : 'Spam'; + inlineConfirm( + `Permanently delete all messages in ${label}? This cannot be undone.`, + async () => { + const r = await api('POST','/folders/'+folderId+'/empty'); + if (r?.ok) { + toast(`Emptied ${label} (${r.deleted||0} messages)`, 'success'); + // Remove locally + S.messages = S.messages.filter(m=>m.folder_id!==folderId); + if (S.currentMessage && S.currentFolder===folderId) resetDetail(); + await loadFolders(); + if (S.currentFolder===folderId) renderMessageList(); + } else toast('Failed to empty folder','error'); + } + ); +} + async function confirmHideFolder(folderId) { const f = S.folders.find(f=>f.id===folderId); if (!f) return; @@ -639,9 +682,16 @@ async function bulkMarkRead(read) { } async function bulkDelete() { - await Promise.all([...SEL.ids].map(id=>api('DELETE','/messages/'+id))); - SEL.ids.forEach(id=>{S.messages=S.messages.filter(m=>m.id!==id);}); - SEL.ids.clear(); renderMessageList(); + const count = SEL.ids.size; + inlineConfirm( + `Delete ${count} message${count===1?'':'s'}? This cannot be undone.`, + async () => { + const ids = [...SEL.ids]; + await Promise.all(ids.map(id=>api('DELETE','/messages/'+id))); + ids.forEach(id=>{S.messages=S.messages.filter(m=>m.id!==id);}); + SEL.ids.clear(); renderMessageList(); loadFolders(); + } + ); } function loadMoreMessages(){ S.currentPage++; loadMessages(true); } @@ -655,7 +705,11 @@ async function openMessage(id) { S.currentMessage=msg; renderMessageDetail(msg, false); const li=S.messages.find(m=>m.id===id); - if (li&&!li.is_read){li.is_read=true;renderMessageList();} + if (li&&!li.is_read){ + li.is_read=true; renderMessageList(); + // Sync read status to server (enqueues IMAP op via backend) + api('PUT','/messages/'+id+'/read',{read:true}); + } } function renderMessageDetail(msg, showRemoteContent) { @@ -1249,3 +1303,169 @@ function _bootApp() { // Run immediately — DOM is ready since this script is at end of _bootApp(); + +// ── Real-time poller + notifications ──────────────────────────────────────── +// Polls /api/poll every 20s for unread count changes and new message detection. +// When new messages arrive: updates badge instantly, shows corner toast, +// and fires a browser OS notification if permission granted. + +const POLLER = { + lastKnownID: 0, // highest message ID we've seen + timer: null, + active: false, + notifGranted: false, +}; + +async function startPoller() { + // Request browser notification permission (non-blocking) + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission().then(p => { + POLLER.notifGranted = p === 'granted'; + }); + } else if ('Notification' in window) { + POLLER.notifGranted = Notification.permission === 'granted'; + } + + POLLER.active = true; + schedulePoll(); +} + +function schedulePoll() { + if (!POLLER.active) return; + POLLER.timer = setTimeout(runPoll, 20000); // 20 second interval +} + +async function runPoll() { + if (!POLLER.active) return; + try { + const data = await api('GET', '/poll?since=' + POLLER.lastKnownID); + if (!data) { schedulePoll(); return; } + + // Update badge immediately without full loadFolders() + updateUnreadBadgeFromPoll(data.inbox_unread); + + // New messages arrived + if (data.has_new && data.newest_id > POLLER.lastKnownID) { + const prevID = POLLER.lastKnownID; + POLLER.lastKnownID = data.newest_id; + + // Fetch new message details for notifications + const newData = await api('GET', '/new-messages?since=' + prevID); + const newMsgs = newData?.messages || []; + + if (newMsgs.length > 0) { + showNewMailToast(newMsgs); + sendOSNotification(newMsgs); + } + + // Refresh current view if we're looking at inbox/unified + const isInboxView = S.currentFolder === 'unified' || + S.folders.find(f => f.id === S.currentFolder && f.folder_type === 'inbox'); + if (isInboxView) { + await loadMessages(); + await loadFolders(); + } else { + await loadFolders(); // update counts in sidebar + } + } + } catch(e) { + // Network error — silent, retry next cycle + } + schedulePoll(); +} + +// Update the unread badge in the sidebar and browser tab title +// without triggering a full folder reload +function updateUnreadBadgeFromPoll(inboxUnread) { + const badge = document.getElementById('unread-total'); + if (!badge) return; + if (inboxUnread > 0) { + badge.textContent = inboxUnread > 99 ? '99+' : inboxUnread; + badge.style.display = ''; + } else { + badge.style.display = 'none'; + } + // Update browser tab title + const base = 'GoMail'; + document.title = inboxUnread > 0 ? `(${inboxUnread}) ${base}` : base; +} + +// Corner toast notification for new mail +function showNewMailToast(msgs) { + const existing = document.getElementById('newmail-toast'); + if (existing) existing.remove(); + + const count = msgs.length; + const first = msgs[0]; + const fromLabel = first.from_name || first.from_email || 'Unknown'; + const subject = first.subject || '(no subject)'; + + const text = count === 1 + ? `${escHtml(fromLabel)}
${escHtml(subject)}` + : `${count} new messages
${escHtml(fromLabel)}: ${escHtml(subject)}`; + + const el = document.createElement('div'); + el.id = 'newmail-toast'; + el.className = 'newmail-toast'; + el.innerHTML = ` +
+
${text}
+ `; + + // Click to open the message + el.addEventListener('click', (e) => { + if (e.target.classList.contains('newmail-toast-close')) return; + el.remove(); + if (count === 1) { + selectFolder( + S.folders.find(f=>f.folder_type==='inbox')?.id || 'unified', + 'Inbox' + ); + setTimeout(()=>openMessage(first.id), 400); + } else { + selectFolder('unified', 'Unified Inbox'); + } + }); + + document.body.appendChild(el); + + // Auto-dismiss after 6s + setTimeout(() => { if (el.parentElement) el.remove(); }, 6000); +} + +function escHtml(s) { + return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +// OS / browser notification +function sendOSNotification(msgs) { + if (!POLLER.notifGranted || !('Notification' in window)) return; + const count = msgs.length; + const first = msgs[0]; + const title = count === 1 + ? (first.from_name || first.from_email || 'New message') + : `${count} new messages in GoMail`; + const body = count === 1 + ? (first.subject || '(no subject)') + : `${first.from_name || first.from_email}: ${first.subject || '(no subject)'}`; + + try { + const n = new Notification(title, { + body, + icon: '/static/icons/icon-192.png', // use if you have one, else falls back gracefully + tag: 'gowebmail-new', // replaces previous if still visible + }); + n.onclick = () => { + window.focus(); + n.close(); + if (count === 1) { + selectFolder(S.folders.find(f=>f.folder_type==='inbox')?.id||'unified','Inbox'); + setTimeout(()=>openMessage(first.id), 400); + } + }; + // Auto-close OS notification after 8s + setTimeout(()=>n.close(), 8000); + } catch(e) { + // Some browsers block even with granted permission in certain contexts + } +} diff --git a/web/static/js/gomail.js b/web/static/js/gowebmail.js similarity index 100% rename from web/static/js/gomail.js rename to web/static/js/gowebmail.js diff --git a/web/templates/admin.html b/web/templates/admin.html index 9586e27..df6def6 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -1,5 +1,5 @@ {{template "base" .}} -{{define "title"}}GoMail Admin{{end}} +{{define "title"}}GoWebMail Admin{{end}} {{define "body_class"}}admin-page{{end}} {{define "body"}}
@@ -7,7 +7,7 @@
diff --git a/web/templates/app.html b/web/templates/app.html index 881e272..950c2ae 100644 --- a/web/templates/app.html +++ b/web/templates/app.html @@ -1,5 +1,5 @@ {{template "base" .}} -{{define "title"}}GoMail{{end}} +{{define "title"}}GoWebMail{{end}} {{define "body_class"}}app-page{{end}} {{define "body"}} @@ -9,9 +9,9 @@