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
|
||||||
data/*.db-shm
|
data/*.db-shm
|
||||||
data/*db-wal
|
data/*db-wal
|
||||||
data/gomail.conf
|
data/gowebmail.conf
|
||||||
data/*.txt
|
data/*.txt
|
||||||
14
Dockerfile
14
Dockerfile
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module github.com/yourusername/gomail
|
module github.com/ghostersk/gowebmail
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
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 = `
|
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">
|
||||||
|
|||||||
@@ -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,'&').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" .}}
|
{{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">
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user