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 0000000..21040b2 Binary files /dev/null and b/web/static/img/favicon.png differ 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 @@