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

2
.gitignore vendored
View File

@@ -2,5 +2,5 @@ data/envs
data/*.db data/*.db
data/*.db-shm data/*.db-shm
data/*db-wal data/*db-wal
data/gomail.conf data/gowebmail.conf
data/*.txt data/*.txt

View File

@@ -7,24 +7,24 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 ---- # ---- Runtime ----
FROM alpine:3.19 FROM alpine:3.19
RUN apk add --no-cache sqlite-libs ca-certificates tzdata RUN apk add --no-cache sqlite-libs ca-certificates tzdata
WORKDIR /app WORKDIR /app
COPY --from=builder /app/gomail . COPY --from=builder /app/gowebmail .
COPY --from=builder /app/web ./web COPY --from=builder /app/web ./web
RUN mkdir -p /data && addgroup -S gomail && adduser -S gomail -G gomail RUN mkdir -p /data && addgroup -S gowebmail && adduser -S gowebmail -G gowebmail
RUN chown -R gomail:gomail /app /data RUN chown -R gowebmail:gowebmail /app /data
USER gomail USER gowebmail
VOLUME ["/data"] VOLUME ["/data"]
EXPOSE 8080 EXPOSE 8080
ENV DB_PATH=/data/gomail.db ENV DB_PATH=/data/gowebmail.db
ENV LISTEN_ADDR=:8080 ENV LISTEN_ADDR=:8080
CMD ["./gomail"] CMD ["./gowebmail"]

View File

@@ -41,7 +41,7 @@ Visit http://localhost:8080, default login admin/admin, register an account, the
```bash ```bash
git clone https://github.com/ghostersk/gowebmail && cd gowebmail git clone https://github.com/ghostersk/gowebmail && cd gowebmail
go run ./cmd/server/main.go 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 # then restart the app
``` ```
### Reset Admin password, MFA ### Reset Admin password, MFA
@@ -115,7 +115,7 @@ golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints
## Building for Production ## Building for Production
```bash ```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. CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler.

View File

@@ -10,11 +10,11 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/yourusername/gomail/config" "github.com/ghostersk/gowebmail/config"
"github.com/yourusername/gomail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/yourusername/gomail/internal/handlers" "github.com/ghostersk/gowebmail/internal/handlers"
"github.com/yourusername/gomail/internal/middleware" "github.com/ghostersk/gowebmail/internal/middleware"
"github.com/yourusername/gomail/internal/syncer" "github.com/ghostersk/gowebmail/internal/syncer"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -22,9 +22,9 @@ import (
func main() { func main() {
// ── CLI admin commands (run without starting the HTTP server) ────────── // ── CLI admin commands (run without starting the HTTP server) ──────────
// Usage: // Usage:
// ./gomail --list-admin list all admin usernames // ./gowebmail --list-admin list all admin usernames
// ./gomail --pw <username> <pass> reset an admin's password // ./gowebmail --pw <username> <pass> reset an admin's password
// ./gomail --mfa-off <username> disable MFA for an admin // ./gowebmail --mfa-off <username> disable MFA for an admin
args := os.Args[1:] args := os.Args[1:]
if len(args) > 0 { if len(args) > 0 {
switch args[0] { switch args[0] {
@@ -33,14 +33,14 @@ func main() {
return return
case "--pw": case "--pw":
if len(args) < 3 { if len(args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: gomail --pw <username> \"<password>\"") fmt.Fprintln(os.Stderr, "Usage: gowebmail --pw <username> \"<password>\"")
os.Exit(1) os.Exit(1)
} }
runResetPassword(args[1], args[2]) runResetPassword(args[1], args[2])
return return
case "--mfa-off": case "--mfa-off":
if len(args) < 2 { if len(args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: gomail --mfa-off <username>") fmt.Fprintln(os.Stderr, "Usage: gowebmail --mfa-off <username>")
os.Exit(1) os.Exit(1)
} }
runDisableMFA(args[1]) runDisableMFA(args[1])
@@ -83,7 +83,9 @@ func main() {
r.PathPrefix("/static/").Handler( r.PathPrefix("/static/").Handler(
http.StripPrefix("/static/", http.FileServer(http.Dir("./web/static/"))), 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 // Public auth routes
auth := r.PathPrefix("/auth").Subrouter() auth := r.PathPrefix("/auth").Subrouter()
auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET") 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]+}/visibility", h.API.SetFolderVisibility).Methods("PUT")
api.HandleFunc("/folders/{id:[0-9]+}/count", h.API.CountFolderMessages).Methods("GET") 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]+}/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("/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.GetSyncInterval).Methods("GET")
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT") api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
@@ -206,7 +212,7 @@ func main() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() { 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 { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err) log.Fatalf("server: %v", err)
} }
@@ -289,19 +295,18 @@ func printHelp() {
fmt.Print(`GoMail — Admin CLI fmt.Print(`GoMail — Admin CLI
Usage: Usage:
gomail Start the mail server gowebmail Start the mail server
gomail --list-admin List all admin accounts (username, email, MFA status) gowebmail --list-admin List all admin accounts (username, email, MFA status)
gomail --pw <username> <pass> Reset password for an admin account gowebmail --pw <username> <pass> Reset password for an admin account
gomail --mfa-off <username> Disable MFA for an admin account gowebmail --mfa-off <username> Disable MFA for an admin account
Examples: Examples:
./gomail --list-admin ./gowebmail --list-admin
./gomail --pw admin "NewSecurePass123" ./gowebmail --pw admin "NewSecurePass123"
./gomail --mfa-off admin ./gowebmail --mfa-off admin
Note: These commands only work on admin accounts. Note: These commands only work on admin accounts.
Regular user management is done through the web UI. Regular user management is done through the web UI.
Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc). Requires the same environment variables as the server (DB_PATH, ENCRYPTION_KEY, etc).
`) `)
} }

View File

@@ -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 package config
import ( import (
@@ -43,7 +43,7 @@ type Config struct {
MicrosoftRedirectURL string // auto-derived from BaseURL if blank MicrosoftRedirectURL string // auto-derived from BaseURL if blank
} }
const configPath = "./data/gomail.conf" const configPath = "./data/gowebmail.conf"
type configField struct { type configField struct {
key string key string
@@ -52,7 +52,7 @@ type configField struct {
} }
// allFields is the single source of truth for config keys. // 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{ var allFields = []configField{
{ {
key: "HOSTNAME", key: "HOSTNAME",
@@ -120,7 +120,7 @@ var allFields = []configField{
}, },
{ {
key: "DB_PATH", key: "DB_PATH",
defVal: "./data/gomail.db", defVal: "./data/gowebmail.db",
comments: []string{ comments: []string{
"--- Storage ---", "--- Storage ---",
"Path to the SQLite database file.", "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. // Environment variables override file values when set.
func Load() (*Config, error) { func Load() (*Config, error) {
if err := os.MkdirAll("./data", 0700); err != nil { if err := os.MkdirAll("./data", 0700); err != nil {
@@ -215,7 +215,7 @@ func Load() (*Config, error) {
// Auto-generate secrets if missing // Auto-generate secrets if missing
if existing["ENCRYPTION_KEY"] == "" { if existing["ENCRYPTION_KEY"] == "" {
existing["ENCRYPTION_KEY"] = mustHex(32) 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"] == "" { if existing["SESSION_SECRET"] == "" {
existing["SESSION_SECRET"] = mustHex(32) existing["SESSION_SECRET"] = mustHex(32)

View File

@@ -47,7 +47,7 @@ TRUSTED_PROXIES =
# --- Storage --- # --- Storage ---
# Path to the SQLite database file. # 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). # 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. # Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.

View File

@@ -1,12 +1,12 @@
version: '3.9' version: '3.9'
services: services:
gomail: gowebmail:
build: . build: .
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- gomail-data:/data - gowebmail-data:/data
environment: environment:
# REQUIRED: Generate with: openssl rand -hex 32 # REQUIRED: Generate with: openssl rand -hex 32
ENCRYPTION_KEY: "" ENCRYPTION_KEY: ""
@@ -32,4 +32,4 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
gomail-data: gowebmail-data:

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/yourusername/gomail module github.com/ghostersk/gowebmail
go 1.26 go 1.26

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/yourusername/gomail/internal/crypto" "github.com/ghostersk/gowebmail/internal/crypto"
"github.com/yourusername/gomail/internal/models" "github.com/ghostersk/gowebmail/internal/models"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -526,7 +526,6 @@ func (d *DB) ListAuditLogs(page, pageSize int, eventFilter string) (*models.Audi
}, rows.Err() }, rows.Err()
} }
// ---- Email Accounts ---- // ---- Email Accounts ----
func (d *DB) CreateAccount(a *models.EmailAccount) error { 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) COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE account_id=? AND full_path=?`, accountID, fullPath, 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) ).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 { if err == sql.ErrNoRows {
return nil, nil 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. // 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 { func (d *DB) SetFolderVisibility(folderID, userID int64, isHidden, syncEnabled bool) error {
ih, se := 0, 0 ih, se := 0, 0
if isHidden { ih = 1 } if isHidden {
if syncEnabled { se = 1 } ih = 1
}
if syncEnabled {
se = 1
}
_, err := d.sql.Exec(` _, err := d.sql.Exec(`
UPDATE folders SET is_hidden=?, sync_enabled=? UPDATE folders SET is_hidden=?, sync_enabled=?
WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`, 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) COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
FROM folders WHERE id=?`, folderID, FROM folders WHERE id=?`, folderID,
).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled) ).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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -1465,3 +1470,96 @@ func boolToInt(b bool) int {
} }
return 0 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()
}

View File

@@ -21,7 +21,7 @@ import (
"github.com/emersion/go-imap" "github.com/emersion/go-imap"
"github.com/emersion/go-imap/client" "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) { func imapHostFor(provider gomailModels.AccountProvider) (string, int) {

View File

@@ -6,11 +6,11 @@ import (
"strconv" "strconv"
"strings" "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/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). // 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 // Sanitize: strip password hash
type safeUser struct { type safeUser struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"` Username string `json:"username"`
Role models.UserRole `json:"role"` Role models.UserRole `json:"role"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
MFAEnabled bool `json:"mfa_enabled"` MFAEnabled bool `json:"mfa_enabled"`
LastLoginAt interface{} `json:"last_login_at"` LastLoginAt interface{} `json:"last_login_at"`
CreatedAt interface{} `json:"created_at"` CreatedAt interface{} `json:"created_at"`
} }
result := make([]safeUser, 0, len(users)) result := make([]safeUser, 0, len(users))
for _, u := range 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) targetID, _ := strconv.ParseInt(vars["id"], 10, 64)
var req struct { var req struct {
IsActive *bool `json:"is_active"` IsActive *bool `json:"is_active"`
Password string `json:"password"` Password string `json:"password"`
Role string `json:"role"` Role string `json:"role"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request") h.writeError(w, http.StatusBadRequest, "invalid request")

View File

@@ -11,20 +11,20 @@ import (
"strings" "strings"
"time" "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/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. // APIHandler handles all /api/* JSON endpoints.
type APIHandler struct { type APIHandler struct {
db *db.DB db *db.DB
cfg *config.Config cfg *config.Config
syncer *syncer.Scheduler syncer *syncer.Scheduler
} }
func (h *APIHandler) writeJSON(w http.ResponseWriter, v interface{}) { 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) { func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Password string `json:"password"` Password string `json:"password"`
IMAPHost string `json:"imap_host"` IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"` IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"` SMTPPort int `json:"smtp_port"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request body") 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{ account := &models.EmailAccount{
UserID: userID, Provider: models.ProviderIMAPSMTP, UserID: userID, Provider: models.ProviderIMAPSMTP,
EmailAddress: req.Email, DisplayName: req.DisplayName, EmailAddress: req.Email, DisplayName: req.DisplayName,
AccessToken: req.Password, AccessToken: req.Password,
IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort, IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort,
SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort, SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort,
Color: color, IsActive: true, Color: color, IsActive: true,
} }
@@ -170,12 +170,12 @@ func (h *APIHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) {
} }
var req struct { var req struct {
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Password string `json:"password"` Password string `json:"password"`
IMAPHost string `json:"imap_host"` IMAPHost string `json:"imap_host"`
IMAPPort int `json:"imap_port"` IMAPPort int `json:"imap_port"`
SMTPHost string `json:"smtp_host"` SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"` SMTPPort int `json:"smtp_port"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request") 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) { func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r) userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id") messageID := pathInt64(r, "id")
var req struct{ Read bool `json:"read"` } var req struct {
Read bool `json:"read"`
}
json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
// Update local DB first // 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) uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
if err == nil && uid != 0 && account != nil { if err == nil && uid != 0 && account != nil {
val := "0" val := "0"
if req.Read { val = "1" } if req.Read {
val = "1"
}
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "flag_read", AccountID: account.ID, OpType: "flag_read",
RemoteUID: uid, FolderPath: folderPath, Extra: val, 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) uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
if ierr == nil && uid != 0 && account != nil { if ierr == nil && uid != 0 && account != nil {
val := "0" val := "0"
if starred { val = "1" } if starred {
val = "1"
}
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{ h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
AccountID: account.ID, OpType: "flag_star", AccountID: account.ID, OpType: "flag_star",
RemoteUID: uid, FolderPath: folderPath, Extra: val, 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) { func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r) userID := middleware.GetUserID(r)
messageID := pathInt64(r, "id") 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 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.FolderID == 0 {
h.writeError(w, http.StatusBadRequest, "folder_id required") h.writeError(w, http.StatusBadRequest, "folder_id required")
return return
@@ -967,3 +975,90 @@ func (h *APIHandler) AddRemoteContentWhitelist(w http.ResponseWriter, r *http.Re
} }
h.writeJSON(w, map[string]bool{"ok": true}) 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})
}

View File

@@ -3,8 +3,8 @@ package handlers
import ( import (
"net/http" "net/http"
"github.com/yourusername/gomail/config" "github.com/ghostersk/gowebmail/config"
"github.com/yourusername/gomail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
) )
// AppHandler serves the main app pages using the shared Renderer. // AppHandler serves the main app pages using the shared Renderer.

View File

@@ -7,13 +7,13 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/yourusername/gomail/config" "github.com/ghostersk/gowebmail/config"
goauth "github.com/yourusername/gomail/internal/auth" goauth "github.com/ghostersk/gowebmail/internal/auth"
"github.com/yourusername/gomail/internal/crypto" "github.com/ghostersk/gowebmail/internal/crypto"
"github.com/yourusername/gomail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/yourusername/gomail/internal/mfa" "github.com/ghostersk/gowebmail/internal/mfa"
"github.com/yourusername/gomail/internal/middleware" "github.com/ghostersk/gowebmail/internal/middleware"
"github.com/yourusername/gomail/internal/models" "github.com/ghostersk/gowebmail/internal/models"
"golang.org/x/oauth2" "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) { func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r) userID := middleware.GetUserID(r)
var req struct{ Code string `json:"code"` } var req struct {
Code string `json:"code"`
}
json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID) 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) { func (h *AuthHandler) MFADisable(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r) userID := middleware.GetUserID(r)
var req struct{ Code string `json:"code"` } var req struct {
Code string `json:"code"`
}
json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
user, _ := h.db.GetUserByID(userID) user, _ := h.db.GetUserByID(userID)

View File

@@ -3,9 +3,9 @@ package handlers
import ( import (
"log" "log"
"github.com/yourusername/gomail/config" "github.com/ghostersk/gowebmail/config"
"github.com/yourusername/gomail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/yourusername/gomail/internal/syncer" "github.com/ghostersk/gowebmail/internal/syncer"
) )
type Handlers struct { type Handlers struct {

View File

@@ -9,9 +9,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/yourusername/gomail/config" "github.com/ghostersk/gowebmail/config"
"github.com/yourusername/gomail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/yourusername/gomail/internal/models" "github.com/ghostersk/gowebmail/internal/models"
) )
type contextKey string type contextKey string

View File

@@ -12,9 +12,9 @@ import (
"sync" "sync"
"time" "time"
"github.com/yourusername/gomail/internal/db" "github.com/ghostersk/gowebmail/internal/db"
"github.com/yourusername/gomail/internal/email" "github.com/ghostersk/gowebmail/internal/email"
"github.com/yourusername/gomail/internal/models" "github.com/ghostersk/gowebmail/internal/models"
) )
// Scheduler coordinates all background sync activity. // Scheduler coordinates all background sync activity.
@@ -24,8 +24,8 @@ type Scheduler struct {
wg sync.WaitGroup wg sync.WaitGroup
// push channels: accountID -> channel to signal "something changed on server" // push channels: accountID -> channel to signal "something changed on server"
pushMu sync.Mutex pushMu sync.Mutex
pushCh map[int64]chan struct{} pushCh map[int64]chan struct{}
} }
// New creates a new Scheduler. // New creates a new Scheduler.
@@ -570,5 +570,3 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
return s.syncFolder(c, account, folder) return s.syncFolder(c, account, folder)
} }

View File

@@ -145,6 +145,7 @@ body.app-page{overflow:hidden}
border-right:1px solid var(--border);display:flex;flex-direction:column;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); .sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between} 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{display:flex;align-items:center;gap:8px}
.logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px; .logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px;
display:flex;align-items:center;justify-content:center;flex-shrink:0} 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} color:var(--text2);transition:background .1s;white-space:nowrap}
.filter-opt:hover{background:var(--surface3);color:var(--text)} .filter-opt:hover{background:var(--surface3);color:var(--text)}
.filter-sep-line{height:1px;background:var(--border);margin:3px 0} .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)}
}

BIN
web/static/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -209,7 +209,7 @@ async function renderSettings() {
el.innerHTML = ` el.innerHTML = `
<div class="admin-page-header"> <div class="admin-page-header">
<h1>Application Settings</h1> <h1>Application Settings</h1>
<p>Changes are saved to <code style="font-family:monospace;background:var(--surface3);padding:2px 6px;border-radius:4px">data/gomail.conf</code> and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.</p> <p>Changes are saved to <code style="font-family:monospace;background:var(--surface3);padding:2px 6px;border-radius:4px">data/gowebmail.conf</code> and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.</p>
</div> </div>
<div id="settings-alert" style="display:none"></div> <div id="settings-alert" style="display:none"></div>
<div class="admin-card"> <div class="admin-card">

View File

@@ -31,6 +31,10 @@ async function init() {
await loadAccounts(); await loadAccounts();
await loadFolders(); await loadFolders();
await loadMessages(); 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); const p = new URLSearchParams(location.search);
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); } if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'','/'); }
@@ -44,6 +48,7 @@ async function init() {
}); });
initComposeDragResize(); initComposeDragResize();
startPoller();
} }
// ── Providers ────────────────────────────────────────────────────────────── // ── Providers ──────────────────────────────────────────────────────────────
@@ -372,11 +377,19 @@ function showFolderMenu(e, folderId) {
<span class="ctx-sub-arrow"></span> <span class="ctx-sub-arrow"></span>
<div class="ctx-submenu">${moveItems}</div> <div class="ctx-submenu">${moveItems}</div>
</div>` : ''; </div>` : '';
const isTrashOrSpam = f.folder_type==='trash' || f.folder_type==='spam';
const emptyEntry = isTrashOrSpam
? `<div class="ctx-item danger" onclick="confirmEmptyFolder(${folderId});closeMenu()">🗑 Empty ${f.name}</div>` : '';
const disabledCount = S.folders.filter(x=>x.account_id===f.account_id&&!x.sync_enabled).length;
const enableAllEntry = disabledCount > 0
? `<div class="ctx-item" onclick="enableAllFolderSync(${f.account_id});closeMenu()">↻ Enable sync for all folders (${disabledCount})</div>` : '';
showCtxMenu(e, ` showCtxMenu(e, `
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div> <div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div> <div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
${enableAllEntry}
<div class="ctx-sep"></div> <div class="ctx-sep"></div>
${moveEntry} ${moveEntry}
${emptyEntry}
<div class="ctx-item" onclick="confirmHideFolder(${folderId});closeMenu()">👁 Hide from sidebar</div> <div class="ctx-item" onclick="confirmHideFolder(${folderId});closeMenu()">👁 Hide from sidebar</div>
<div class="ctx-item danger" onclick="confirmDeleteFolder(${folderId});closeMenu()">🗑 Delete folder</div>`); <div class="ctx-item danger" onclick="confirmDeleteFolder(${folderId});closeMenu()">🗑 Delete folder</div>`);
} }
@@ -400,6 +413,36 @@ async function toggleFolderSync(folderId) {
} else toast('Update failed','error'); } 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) { async function confirmHideFolder(folderId) {
const f = S.folders.find(f=>f.id===folderId); const f = S.folders.find(f=>f.id===folderId);
if (!f) return; if (!f) return;
@@ -639,9 +682,16 @@ async function bulkMarkRead(read) {
} }
async function bulkDelete() { async function bulkDelete() {
await Promise.all([...SEL.ids].map(id=>api('DELETE','/messages/'+id))); const count = SEL.ids.size;
SEL.ids.forEach(id=>{S.messages=S.messages.filter(m=>m.id!==id);}); inlineConfirm(
SEL.ids.clear(); renderMessageList(); `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); } function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
@@ -655,7 +705,11 @@ async function openMessage(id) {
S.currentMessage=msg; S.currentMessage=msg;
renderMessageDetail(msg, false); renderMessageDetail(msg, false);
const li=S.messages.find(m=>m.id===id); 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) { function renderMessageDetail(msg, showRemoteContent) {
@@ -1249,3 +1303,169 @@ function _bootApp() {
// Run immediately — DOM is ready since this script is at end of <body> // Run immediately — DOM is ready since this script is at end of <body>
_bootApp(); _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
? `<strong>${escHtml(fromLabel)}</strong><br><span>${escHtml(subject)}</span>`
: `<strong>${count} new messages</strong><br><span>${escHtml(fromLabel)}: ${escHtml(subject)}</span>`;
const el = document.createElement('div');
el.id = 'newmail-toast';
el.className = 'newmail-toast';
el.innerHTML = `
<div class="newmail-toast-icon">✉</div>
<div class="newmail-toast-body">${text}</div>
<button class="newmail-toast-close" onclick="this.parentElement.remove()">✕</button>`;
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 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
}
}

View File

@@ -1,5 +1,5 @@
{{template "base" .}} {{template "base" .}}
{{define "title"}}GoMail Admin{{end}} {{define "title"}}GoWebMail Admin{{end}}
{{define "body_class"}}admin-page{{end}} {{define "body_class"}}admin-page{{end}}
{{define "body"}} {{define "body"}}
<div class="admin-layout"> <div class="admin-layout">
@@ -7,7 +7,7 @@
<div class="logo-area"> <div class="logo-area">
<a href="/"> <a href="/">
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div> <div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text">GoMail</span> <span class="logo-text">GoWebMail</span>
</a> </a>
</div> </div>
<div class="admin-nav"> <div class="admin-nav">

View File

@@ -1,5 +1,5 @@
{{template "base" .}} {{template "base" .}}
{{define "title"}}GoMail{{end}} {{define "title"}}GoWebMail{{end}}
{{define "body_class"}}app-page{{end}} {{define "body_class"}}app-page{{end}}
{{define "body"}} {{define "body"}}
@@ -9,9 +9,9 @@
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo"> <div class="logo">
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div> <div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text">GoMail</span> <span class="logo-text"><a href="/">GoWebMail</a></span>
</div> </div>
<button class="compose-btn" onclick="openCompose()">+ Compose</button> <button class="compose-btn" onclick="openCompose()">+ New</button>
</div> </div>
<div class="nav-section"> <div class="nav-section">
@@ -30,7 +30,7 @@
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="user-info"> <div class="user-info">
<span class="user-name" id="user-display">...</span> <span class="user-name" id="user-display">...</span>
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Admin</a> <a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Server Administration</a>
</div> </div>
<div class="footer-actions"> <div class="footer-actions">
<button class="icon-btn" id="accounts-btn" onclick="toggleAccountsMenu(event)" title="Manage accounts"> <button class="icon-btn" id="accounts-btn" onclick="toggleAccountsMenu(event)" title="Manage accounts">
@@ -308,5 +308,5 @@
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}
<script src="/static/js/app.js?v=11"></script> <script src="/static/js/app.js?v=12"></script>
{{end}} {{end}}

View File

@@ -3,14 +3,14 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoMail{{end}}</title> <title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gomail.css?v=11"> <link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
{{block "head_extra" .}}{{end}} {{block "head_extra" .}}{{end}}
</head> </head>
<body class="{{block "body_class" .}}{{end}}"> <body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}} {{block "body" .}}{{end}}
<script src="/static/js/gomail.js?v=11"></script> <script src="/static/js/gowebmail.js?v=12"></script>
{{block "scripts" .}}{{end}} {{block "scripts" .}}{{end}}
</body> </body>
</html> </html>

View File

@@ -1,21 +1,20 @@
{{template "base" .}} {{template "base" .}}
{{define "title"}}GoMail — Sign In{{end}} {{define "title"}}GoWebMail — Sign In{{end}}
{{define "body_class"}}auth-page{{end}} {{define "body_class"}}auth-page{{end}}
{{define "body"}} {{define "body"}}
<div class="auth-card"> <div class="auth-card">
<div class="logo"> <div class="logo">
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div> <div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text">GoMail</span> <span class="logo-text">GoWebMail</span>
</div> </div>
<h1>Welcome back</h1> <h1>Welcome back</h1>
<p class="subtitle">Sign in to your GoMail account</p> <p class="subtitle">Sign in to your Web Mail Client</p>
<div id="err" class="alert error" style="display:none"></div> <div id="err" class="alert error" style="display:none"></div>
<form method="POST" action="/auth/login"> <form method="POST" action="/auth/login">
<div class="field"><label>Username or Email</label><input type="text" name="username" placeholder="admin" required autocomplete="username"></div> <div class="field"><label>Username or Email</label><input type="text" name="username" placeholder="admin" required autocomplete="username"></div>
<div class="field"><label>Password</label><input type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div> <div class="field"><label>Password</label><input type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Sign In</button> <button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Sign In</button>
</form> </form>
<p style="font-size:12px;color:var(--muted);margin-top:16px;text-align:center">Default credentials: <strong>admin</strong> / <strong>admin</strong></p>
</div> </div>
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}

View File

@@ -1,11 +1,11 @@
{{template "base" .}} {{template "base" .}}
{{define "title"}}GoMail — Two-Factor Auth{{end}} {{define "title"}}GoWebMail — Two-Factor Auth{{end}}
{{define "body_class"}}auth-page{{end}} {{define "body_class"}}auth-page{{end}}
{{define "body"}} {{define "body"}}
<div class="auth-card"> <div class="auth-card">
<div class="logo"> <div class="logo">
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div> <div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
<span class="logo-text">GoMail</span> <span class="logo-text">GoWebMail</span>
</div> </div>
<h1>Two-Factor Auth</h1> <h1>Two-Factor Auth</h1>
<p class="subtitle">Enter the 6-digit code from your authenticator app</p> <p class="subtitle">Enter the 6-digit code from your authenticator app</p>