mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
update name, project refference and synchronization
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,5 +2,5 @@ data/envs
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*db-wal
|
||||
data/gomail.conf
|
||||
data/gowebmail.conf
|
||||
data/*.txt
|
||||
14
Dockerfile
14
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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <username> <pass> reset an admin's password
|
||||
// ./gomail --mfa-off <username> disable MFA for an admin
|
||||
// ./gowebmail --list-admin list all admin usernames
|
||||
// ./gowebmail --pw <username> <pass> reset an admin's password
|
||||
// ./gowebmail --mfa-off <username> 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 <username> \"<password>\"")
|
||||
fmt.Fprintln(os.Stderr, "Usage: gowebmail --pw <username> \"<password>\"")
|
||||
os.Exit(1)
|
||||
}
|
||||
runResetPassword(args[1], args[2])
|
||||
return
|
||||
case "--mfa-off":
|
||||
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)
|
||||
}
|
||||
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 <username> <pass> Reset password for an admin account
|
||||
gomail --mfa-off <username> Disable MFA for an admin account
|
||||
gowebmail Start the mail server
|
||||
gowebmail --list-admin List all admin accounts (username, email, MFA status)
|
||||
gowebmail --pw <username> <pass> Reset password for an admin account
|
||||
gowebmail --mfa-off <username> 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).
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module github.com/yourusername/gomail
|
||||
module github.com/ghostersk/gowebmail
|
||||
|
||||
go 1.26
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -11,20 +11,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/email"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/internal/syncer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/yourusername/gomail/config"
|
||||
"github.com/yourusername/gomail/internal/db"
|
||||
"github.com/yourusername/gomail/internal/email"
|
||||
"github.com/yourusername/gomail/internal/middleware"
|
||||
"github.com/yourusername/gomail/internal/models"
|
||||
"github.com/yourusername/gomail/internal/syncer"
|
||||
)
|
||||
|
||||
// APIHandler handles all /api/* JSON endpoints.
|
||||
type APIHandler struct {
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
syncer *syncer.Scheduler
|
||||
db *db.DB
|
||||
cfg *config.Config
|
||||
syncer *syncer.Scheduler
|
||||
}
|
||||
|
||||
func (h *APIHandler) writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
@@ -93,13 +93,13 @@ func (h *APIHandler) ListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
@@ -128,8 +128,8 @@ func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) {
|
||||
account := &models.EmailAccount{
|
||||
UserID: userID, Provider: models.ProviderIMAPSMTP,
|
||||
EmailAddress: req.Email, DisplayName: req.DisplayName,
|
||||
AccessToken: req.Password,
|
||||
IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort,
|
||||
AccessToken: req.Password,
|
||||
IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort,
|
||||
SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort,
|
||||
Color: color, IsActive: true,
|
||||
}
|
||||
@@ -170,12 +170,12 @@ func (h *APIHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
@@ -538,7 +538,9 @@ func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
messageID := pathInt64(r, "id")
|
||||
var req struct{ Read bool `json:"read"` }
|
||||
var req struct {
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Update local DB first
|
||||
@@ -548,7 +550,9 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err == nil && uid != 0 && account != nil {
|
||||
val := "0"
|
||||
if req.Read { val = "1" }
|
||||
if req.Read {
|
||||
val = "1"
|
||||
}
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_read",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
@@ -569,7 +573,9 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
|
||||
uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if ierr == nil && uid != 0 && account != nil {
|
||||
val := "0"
|
||||
if starred { val = "1" }
|
||||
if starred {
|
||||
val = "1"
|
||||
}
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_star",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
@@ -582,7 +588,9 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
messageID := pathInt64(r, "id")
|
||||
var req struct{ FolderID int64 `json:"folder_id"` }
|
||||
var req struct {
|
||||
FolderID int64 `json:"folder_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.FolderID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "folder_id required")
|
||||
return
|
||||
@@ -967,3 +975,90 @@ func (h *APIHandler) AddRemoteContentWhitelist(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ---- Empty folder (Trash/Spam) ----
|
||||
|
||||
func (h *APIHandler) EmptyFolder(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
folderID := pathInt64(r, "id")
|
||||
|
||||
// Verify folder is trash or spam before allowing bulk delete
|
||||
folder, err := h.db.GetFolderByID(folderID)
|
||||
if err != nil || folder == nil {
|
||||
h.writeError(w, http.StatusNotFound, "folder not found")
|
||||
return
|
||||
}
|
||||
if folder.FolderType != "trash" && folder.FolderType != "spam" {
|
||||
h.writeError(w, http.StatusBadRequest, "can only empty trash or spam folders")
|
||||
return
|
||||
}
|
||||
|
||||
n, err := h.db.EmptyFolder(folderID, userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to empty folder")
|
||||
return
|
||||
}
|
||||
h.db.UpdateFolderCounts(folderID)
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
// ---- Enable sync for all folders of an account ----
|
||||
|
||||
func (h *APIHandler) EnableAllFolderSync(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
vars := mux.Vars(r)
|
||||
accountIDStr := vars["account_id"]
|
||||
var accountID int64
|
||||
fmt.Sscanf(accountIDStr, "%d", &accountID)
|
||||
if accountID == 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "account_id required")
|
||||
return
|
||||
}
|
||||
n, err := h.db.EnableAllFolderSync(accountID, userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to enable sync")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true, "enabled": n})
|
||||
}
|
||||
|
||||
// ---- Long-poll for unread counts + new message detection ----
|
||||
// GET /api/poll?since=<lastKnownMessageID>
|
||||
// Returns immediately with current counts; client polls every ~20s.
|
||||
|
||||
func (h *APIHandler) PollUnread(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var sinceID int64
|
||||
fmt.Sscanf(r.URL.Query().Get("since"), "%d", &sinceID)
|
||||
|
||||
inboxUnread, totalUnread, newestID, err := h.db.PollUnread(userID, sinceID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "poll failed")
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, map[string]interface{}{
|
||||
"inbox_unread": inboxUnread,
|
||||
"total_unread": totalUnread,
|
||||
"newest_id": newestID,
|
||||
"has_new": newestID > sinceID && sinceID > 0,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Get new messages since ID (for notification content) ----
|
||||
|
||||
func (h *APIHandler) NewMessagesSince(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var sinceID int64
|
||||
fmt.Sscanf(r.URL.Query().Get("since"), "%d", &sinceID)
|
||||
if sinceID == 0 {
|
||||
h.writeJSON(w, map[string]interface{}{"messages": []interface{}{}})
|
||||
return
|
||||
}
|
||||
msgs, err := h.db.GetNewMessagesSince(userID, sinceID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "query failed")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"messages": msgs})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/yourusername/gomail/config"
|
||||
goauth "github.com/yourusername/gomail/internal/auth"
|
||||
"github.com/yourusername/gomail/internal/crypto"
|
||||
"github.com/yourusername/gomail/internal/db"
|
||||
"github.com/yourusername/gomail/internal/mfa"
|
||||
"github.com/yourusername/gomail/internal/middleware"
|
||||
"github.com/yourusername/gomail/internal/models"
|
||||
"github.com/ghostersk/gowebmail/config"
|
||||
goauth "github.com/ghostersk/gowebmail/internal/auth"
|
||||
"github.com/ghostersk/gowebmail/internal/crypto"
|
||||
"github.com/ghostersk/gowebmail/internal/db"
|
||||
"github.com/ghostersk/gowebmail/internal/mfa"
|
||||
"github.com/ghostersk/gowebmail/internal/middleware"
|
||||
"github.com/ghostersk/gowebmail/internal/models"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -155,7 +155,9 @@ func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req struct{ Code string `json:"code"` }
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
user, _ := h.db.GetUserByID(userID)
|
||||
@@ -181,7 +183,9 @@ func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *AuthHandler) MFADisable(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
var req struct{ Code string `json:"code"` }
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
user, _ := h.db.GetUserByID(userID)
|
||||
|
||||
@@ -3,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
BIN
web/static/img/favicon.png
Normal file
BIN
web/static/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -209,7 +209,7 @@ async function renderSettings() {
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<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 id="settings-alert" style="display:none"></div>
|
||||
<div class="admin-card">
|
||||
|
||||
@@ -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) {
|
||||
<span class="ctx-sub-arrow">›</span>
|
||||
<div class="ctx-submenu">${moveItems}</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, `
|
||||
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
|
||||
<div class="ctx-item" onclick="toggleFolderSync(${folderId});closeMenu()">${syncLabel}</div>
|
||||
${enableAllEntry}
|
||||
<div class="ctx-sep"></div>
|
||||
${moveEntry}
|
||||
${emptyEntry}
|
||||
<div class="ctx-item" onclick="confirmHideFolder(${folderId});closeMenu()">👁 Hide from sidebar</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');
|
||||
}
|
||||
|
||||
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 <body>
|
||||
_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,'&').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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}GoMail Admin{{end}}
|
||||
{{define "title"}}GoWebMail Admin{{end}}
|
||||
{{define "body_class"}}admin-page{{end}}
|
||||
{{define "body"}}
|
||||
<div class="admin-layout">
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="logo-area">
|
||||
<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>
|
||||
<span class="logo-text">GoMail</span>
|
||||
<span class="logo-text">GoWebMail</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="admin-nav">
|
||||
|
||||
@@ -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 @@
|
||||
<div class="sidebar-header">
|
||||
<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>
|
||||
<span class="logo-text">GoMail</span>
|
||||
<span class="logo-text"><a href="/">GoWebMail</a></span>
|
||||
</div>
|
||||
<button class="compose-btn" onclick="openCompose()">+ Compose</button>
|
||||
<button class="compose-btn" onclick="openCompose()">+ New</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<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 class="footer-actions">
|
||||
<button class="icon-btn" id="accounts-btn" onclick="toggleAccountsMenu(event)" title="Manage accounts">
|
||||
@@ -308,5 +308,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js?v=11"></script>
|
||||
<script src="/static/js/app.js?v=12"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 rel="stylesheet" href="/static/css/gomail.css?v=11">
|
||||
<link rel="stylesheet" href="/static/css/gowebmail.css?v=12">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{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}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}GoMail — Sign In{{end}}
|
||||
{{define "title"}}GoWebMail — Sign In{{end}}
|
||||
{{define "body_class"}}auth-page{{end}}
|
||||
{{define "body"}}
|
||||
<div class="auth-card">
|
||||
<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>
|
||||
<span class="logo-text">GoMail</span>
|
||||
<span class="logo-text">GoWebMail</span>
|
||||
</div>
|
||||
<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>
|
||||
<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>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>
|
||||
</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>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}GoMail — Two-Factor Auth{{end}}
|
||||
{{define "title"}}GoWebMail — Two-Factor Auth{{end}}
|
||||
{{define "body_class"}}auth-page{{end}}
|
||||
{{define "body"}}
|
||||
<div class="auth-card">
|
||||
<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>
|
||||
<span class="logo-text">GoMail</span>
|
||||
<span class="logo-text">GoWebMail</span>
|
||||
</div>
|
||||
<h1>Two-Factor Auth</h1>
|
||||
<p class="subtitle">Enter the 6-digit code from your authenticator app</p>
|
||||
|
||||
Reference in New Issue
Block a user