mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
Compare commits
10 Commits
96d9d99e6e
...
5d51b9778b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d51b9778b | ||
|
|
b1fe22863a | ||
|
|
12b1a44b96 | ||
|
|
d4a4a5ec30 | ||
|
|
d5027ba7b0 | ||
|
|
faa7dba2df | ||
|
|
0bcd974b3d | ||
|
|
6df2de5f22 | ||
|
|
b118056176 | ||
|
|
1cf003edc4 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
data/envs
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*db-wal
|
||||
data/gomail.conf
|
||||
data/*.txt
|
||||
85
README.md
85
README.md
@@ -3,7 +3,7 @@
|
||||
A self-hosted, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider.
|
||||
|
||||
# Notes:
|
||||
- work still in progress
|
||||
- work still in progress ( gmail and hotmail email not tested yet, just prepared the app for it)
|
||||
- AI is involved in making this work, as I do not have the skill and time to do it on my own
|
||||
- looking for any advice and suggestions to improve it!
|
||||
|
||||
@@ -18,55 +18,48 @@ A self-hosted, encrypted web email client written entirely in Go. Supports Gmail
|
||||
- **Folder navigation** — per-account folder/label browsing
|
||||
- **Full-text search** — across all accounts locally
|
||||
- **Dark-themed web UI** — clean, keyboard-shortcut-friendly interface
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
cmd/server/main.go Entry point, HTTP server setup
|
||||
config/config.go Environment-based config
|
||||
internal/
|
||||
auth/oauth.go OAuth2 flows (Google + Microsoft)
|
||||
crypto/crypto.go AES-256-GCM encryption + bcrypt
|
||||
db/db.go SQLite database with field-level encryption
|
||||
email/imap.go IMAP fetch + SMTP send via XOAUTH2
|
||||
handlers/ HTTP handlers (auth, app, api)
|
||||
middleware/middleware.go Logger, auth guard, security headers
|
||||
models/models.go Data models
|
||||
web/static/
|
||||
login.html Sign-in page
|
||||
register.html Registration page
|
||||
app.html Single-page app (email client UI)
|
||||
```
|
||||
<img width="1213" height="848" alt="image" src="https://github.com/user-attachments/assets/955eda04-e358-4779-80e7-0a9b299ac110" />
|
||||
<img width="1261" height="921" alt="image" src="https://github.com/user-attachments/assets/40ee58e8-6c4b-45c3-974d-98cc8ccc45a5" />
|
||||
<img width="1153" height="907" alt="image" src="https://github.com/user-attachments/assets/ebc92335-f6b7-46ed-b9a2-84512f70e1b2" />
|
||||
<img width="551" height="669" alt="image" src="https://github.com/user-attachments/assets/412585c0-434a-4177-ab04-7db69da9d08a" />
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker Compose (recommended)
|
||||
### Option 1: Build executable
|
||||
|
||||
```bash
|
||||
# 1. Clone / copy the project
|
||||
git clone https://github.com/yourname/gomail && cd gomail
|
||||
|
||||
# 2. Generate secrets
|
||||
export ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
export SESSION_SECRET=$(openssl rand -hex 32)
|
||||
echo "ENCRYPTION_KEY=$ENCRYPTION_KEY" # SAVE THIS — losing it means losing your email cache
|
||||
|
||||
# 3. Add your OAuth2 credentials to docker-compose.yml (see below)
|
||||
# 4. Run
|
||||
ENCRYPTION_KEY=$ENCRYPTION_KEY SESSION_SECRET=$SESSION_SECRET docker compose up
|
||||
git clone https://github.com/ghostersk/gowebmail && cd gowebmail
|
||||
go build -o gowebmail ./cmd/server
|
||||
./gowebmail
|
||||
```
|
||||
|
||||
Visit http://localhost:8080, register an account, then connect your email.
|
||||
Visit http://localhost:8080, default login admin/admin, register an account, then connect your email.
|
||||
|
||||
### Option 2: Run directly
|
||||
|
||||
```bash
|
||||
go build -o gomail ./cmd/server
|
||||
export ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
export SESSION_SECRET=$(openssl rand -hex 32)
|
||||
./gomail
|
||||
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.
|
||||
# then restart the app
|
||||
```
|
||||
### Reset Admin password, MFA
|
||||
|
||||
```bash
|
||||
# List all admins with MFA status
|
||||
./gowebmail --list-admin
|
||||
|
||||
# USERNAME EMAIL MFA
|
||||
# -------- ----- ---
|
||||
# admin admin@example.com ON
|
||||
|
||||
# Reset an admin's password (min 8 chars)
|
||||
./gowebmail --pw admin "NewSecurePass123"
|
||||
|
||||
# Disable MFA so a locked-out admin can log in again
|
||||
./gowebmail --mfa-off admin
|
||||
```
|
||||
## Setting up OAuth2
|
||||
|
||||
### Gmail
|
||||
@@ -90,23 +83,6 @@ export SESSION_SECRET=$(openssl rand -hex 32)
|
||||
4. Create a Client secret
|
||||
5. Set env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `ENCRYPTION_KEY` | **Yes** | 64-char hex string (32 bytes). Auto-generated on first run but must be persisted. |
|
||||
| `SESSION_SECRET` | **Yes** | Random string for session signing. |
|
||||
| `LISTEN_ADDR` | No | Default `:8080` |
|
||||
| `DB_PATH` | No | Default `./data/gomail.db` |
|
||||
| `BASE_URL` | No | Default `http://localhost:8080` |
|
||||
| `GOOGLE_CLIENT_ID` | For Gmail | Google OAuth2 client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | For Gmail | Google OAuth2 client secret |
|
||||
| `GOOGLE_REDIRECT_URL` | No | Default `{BASE_URL}/auth/gmail/callback` |
|
||||
| `MICROSOFT_CLIENT_ID` | For Outlook | Azure AD app client ID |
|
||||
| `MICROSOFT_CLIENT_SECRET` | For Outlook | Azure AD app client secret |
|
||||
| `MICROSOFT_TENANT_ID` | No | Default `common` (multi-tenant) |
|
||||
| `SECURE_COOKIE` | No | Set `true` in production (HTTPS only) |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **ENCRYPTION_KEY** is critical — back it up. Without it, the encrypted SQLite database is unreadable.
|
||||
@@ -145,5 +121,4 @@ CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gomail ./cmd/server
|
||||
CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
This project is licensed under the [GPL-3.0 license](LICENSE).
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -19,6 +20,38 @@ 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
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "--list-admin":
|
||||
runListAdmins()
|
||||
return
|
||||
case "--pw":
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: gomail --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>")
|
||||
os.Exit(1)
|
||||
}
|
||||
runDisableMFA(args[1])
|
||||
return
|
||||
case "--help", "-h":
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normal server startup ──────────────────────────────────────────────
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config load: %v", err)
|
||||
@@ -104,6 +137,7 @@ func main() {
|
||||
api.HandleFunc("/accounts", h.API.ListAccounts).Methods("GET")
|
||||
api.HandleFunc("/accounts", h.API.AddAccount).Methods("POST")
|
||||
api.HandleFunc("/accounts/test", h.API.TestConnection).Methods("POST")
|
||||
api.HandleFunc("/accounts/detect", h.API.DetectMailSettings).Methods("POST")
|
||||
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.GetAccount).Methods("GET")
|
||||
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.UpdateAccount).Methods("PUT")
|
||||
api.HandleFunc("/accounts/{id:[0-9]+}", h.API.DeleteAccount).Methods("DELETE")
|
||||
@@ -118,7 +152,9 @@ func main() {
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/star", h.API.ToggleStar).Methods("PUT")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}/download.eml", h.API.DownloadEML).Methods("GET")
|
||||
api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE")
|
||||
api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET")
|
||||
|
||||
// Remote content whitelist
|
||||
api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET")
|
||||
@@ -133,6 +169,10 @@ func main() {
|
||||
api.HandleFunc("/folders", h.API.ListFolders).Methods("GET")
|
||||
api.HandleFunc("/folders/{account_id:[0-9]+}", h.API.ListAccountFolders).Methods("GET")
|
||||
api.HandleFunc("/folders/{id:[0-9]+}/sync", h.API.SyncFolder).Methods("POST")
|
||||
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]+}", h.API.DeleteFolder).Methods("DELETE")
|
||||
|
||||
api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET")
|
||||
api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT")
|
||||
@@ -178,3 +218,90 @@ func main() {
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// ── CLI helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func openDB() (*db.DB, func()) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
database, err := db.New(cfg.DBPath, cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return database, func() { database.Close() }
|
||||
}
|
||||
|
||||
func runListAdmins() {
|
||||
database, close := openDB()
|
||||
defer close()
|
||||
|
||||
admins, err := database.AdminListAdmins()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(admins) == 0 {
|
||||
fmt.Println("No admin accounts found.")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-24s %-36s %s\n", "USERNAME", "EMAIL", "MFA")
|
||||
fmt.Printf("%-24s %-36s %s\n", "--------", "-----", "---")
|
||||
for _, a := range admins {
|
||||
mfaStatus := "off"
|
||||
if a.MFAEnabled {
|
||||
mfaStatus = "ON"
|
||||
}
|
||||
fmt.Printf("%-24s %-36s %s\n", a.Username, a.Email, mfaStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func runResetPassword(username, password string) {
|
||||
if len(password) < 8 {
|
||||
fmt.Fprintln(os.Stderr, "Error: password must be at least 8 characters")
|
||||
os.Exit(1)
|
||||
}
|
||||
database, close := openDB()
|
||||
defer close()
|
||||
|
||||
if err := database.AdminResetPassword(username, password); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Password updated for admin '%s'.\n", username)
|
||||
}
|
||||
|
||||
func runDisableMFA(username string) {
|
||||
database, close := openDB()
|
||||
defer close()
|
||||
|
||||
if err := database.AdminDisableMFA(username); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("MFA disabled for admin '%s'. They can now log in with password only.\n", username)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Examples:
|
||||
./gomail --list-admin
|
||||
./gomail --pw admin "NewSecurePass123"
|
||||
./gomail --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).
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -165,13 +165,39 @@ func (d *DB) Migrate() error {
|
||||
alterStmts := []string{
|
||||
`ALTER TABLE email_accounts ADD COLUMN sync_days INTEGER NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE email_accounts ADD COLUMN sync_mode TEXT NOT NULL DEFAULT 'days'`,
|
||||
`ALTER TABLE email_accounts ADD COLUMN sync_all_folders INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`,
|
||||
// Folder visibility: is_hidden hides from sidebar; sync_enabled controls auto-sync.
|
||||
// Default: primary folder types sync by default, others don't.
|
||||
`ALTER TABLE folders ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE folders ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 1`,
|
||||
// Plaintext search index column — stores decrypted subject+from+preview for LIKE search.
|
||||
`ALTER TABLE messages ADD COLUMN search_text TEXT NOT NULL DEFAULT ''`,
|
||||
// Per-folder IMAP sync state for incremental/delta sync.
|
||||
`ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE folders ADD COLUMN last_seen_uid INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range alterStmts {
|
||||
d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally
|
||||
}
|
||||
|
||||
// Pending IMAP operations queue — survives server restarts.
|
||||
// op_type: "delete" | "move" | "flag_read" | "flag_star"
|
||||
_, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS pending_imap_ops (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
||||
op_type TEXT NOT NULL,
|
||||
remote_uid INTEGER NOT NULL,
|
||||
folder_path TEXT NOT NULL DEFAULT '',
|
||||
extra TEXT NOT NULL DEFAULT '',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now'))
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create pending_imap_ops: %w", err)
|
||||
}
|
||||
|
||||
// Bootstrap admin account if no users exist
|
||||
return d.bootstrapAdmin()
|
||||
}
|
||||
@@ -271,13 +297,15 @@ func (d *DB) ListUsers() ([]*models.User, error) {
|
||||
u := &models.User{}
|
||||
var mfaSecretEnc, mfaPendingEnc string
|
||||
var lastLogin sql.NullTime
|
||||
var composePopup int
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.IsActive,
|
||||
&u.MFAEnabled, &mfaSecretEnc, &mfaPendingEnc, &lastLogin,
|
||||
&u.CreatedAt, &u.UpdatedAt,
|
||||
&u.CreatedAt, &u.UpdatedAt, &u.SyncInterval, &composePopup,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.ComposePopup = composePopup == 1
|
||||
u.MFASecret, _ = d.enc.Decrypt(mfaSecretEnc)
|
||||
u.MFAPending, _ = d.enc.Decrypt(mfaPendingEnc)
|
||||
if lastLogin.Valid {
|
||||
@@ -299,6 +327,63 @@ func (d *DB) UpdateUserPassword(userID int64, newPassword string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AdminListAdmins returns (username, email, mfa_enabled) for all admin-role users.
|
||||
func (d *DB) AdminListAdmins() ([]struct {
|
||||
Username string
|
||||
Email string
|
||||
MFAEnabled bool
|
||||
}, error) {
|
||||
rows, err := d.sql.Query(`SELECT username, email, mfa_enabled FROM users WHERE role='admin' ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []struct {
|
||||
Username string
|
||||
Email string
|
||||
MFAEnabled bool
|
||||
}
|
||||
for rows.Next() {
|
||||
var r struct {
|
||||
Username string
|
||||
Email string
|
||||
MFAEnabled bool
|
||||
}
|
||||
rows.Scan(&r.Username, &r.Email, &r.MFAEnabled)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AdminResetPassword sets a new password for an admin user by username (admin-only check).
|
||||
func (d *DB) AdminResetPassword(username, newPassword string) error {
|
||||
// Verify user exists and is admin
|
||||
var id int64
|
||||
var role string
|
||||
err := d.sql.QueryRow(`SELECT id, role FROM users WHERE username=?`, username).Scan(&id, &role)
|
||||
if err != nil || id == 0 {
|
||||
return fmt.Errorf("user %q not found", username)
|
||||
}
|
||||
if role != "admin" {
|
||||
return fmt.Errorf("user %q is not an admin (use the web UI for regular users)", username)
|
||||
}
|
||||
return d.UpdateUserPassword(id, newPassword)
|
||||
}
|
||||
|
||||
// AdminDisableMFA disables MFA for an admin user by username (admin-only check).
|
||||
func (d *DB) AdminDisableMFA(username string) error {
|
||||
var id int64
|
||||
var role string
|
||||
err := d.sql.QueryRow(`SELECT id, role FROM users WHERE username=?`, username).Scan(&id, &role)
|
||||
if err != nil || id == 0 {
|
||||
return fmt.Errorf("user %q not found", username)
|
||||
}
|
||||
if role != "admin" {
|
||||
return fmt.Errorf("user %q is not an admin (use the web UI for regular users)", username)
|
||||
}
|
||||
return d.DisableMFA(id)
|
||||
}
|
||||
|
||||
func (d *DB) SetUserActive(userID int64, active bool) error {
|
||||
v := 0
|
||||
if active {
|
||||
@@ -685,25 +770,34 @@ func (d *DB) UpdateFolderCounts(folderID int64) {
|
||||
// ---- Folders ----
|
||||
|
||||
func (d *DB) UpsertFolder(f *models.Folder) error {
|
||||
// On insert: set sync_enabled based on folder type (primary types sync by default)
|
||||
defaultSync := 0
|
||||
switch f.FolderType {
|
||||
case "inbox", "sent", "drafts", "trash", "spam":
|
||||
defaultSync = 1
|
||||
}
|
||||
_, err := d.sql.Exec(`
|
||||
INSERT INTO folders (account_id, name, full_path, folder_type, unread_count, total_count)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
INSERT INTO folders (account_id, name, full_path, folder_type, unread_count, total_count, sync_enabled)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(account_id, full_path) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
folder_type=excluded.folder_type,
|
||||
unread_count=excluded.unread_count,
|
||||
total_count=excluded.total_count`,
|
||||
f.AccountID, f.Name, f.FullPath, f.FolderType, f.UnreadCount, f.TotalCount,
|
||||
f.AccountID, f.Name, f.FullPath, f.FolderType, f.UnreadCount, f.TotalCount, defaultSync,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetFolderByPath(accountID int64, fullPath string) (*models.Folder, error) {
|
||||
f := &models.Folder{}
|
||||
var isHidden, syncEnabled int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
|
||||
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)
|
||||
).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
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -712,7 +806,8 @@ func (d *DB) GetFolderByPath(accountID int64, fullPath string) (*models.Folder,
|
||||
|
||||
func (d *DB) ListFoldersByAccount(accountID int64) ([]*models.Folder, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
|
||||
COALESCE(is_hidden,0), COALESCE(sync_enabled,1)
|
||||
FROM folders WHERE account_id=? ORDER BY folder_type, name`, accountID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -722,9 +817,12 @@ func (d *DB) ListFoldersByAccount(accountID int64) ([]*models.Folder, error) {
|
||||
var folders []*models.Folder
|
||||
for rows.Next() {
|
||||
f := &models.Folder{}
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount); err != nil {
|
||||
var isHidden, syncEnabled int
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.IsHidden = isHidden == 1
|
||||
f.SyncEnabled = syncEnabled == 1
|
||||
folders = append(folders, f)
|
||||
}
|
||||
return folders, rows.Err()
|
||||
@@ -743,19 +841,28 @@ func (d *DB) UpsertMessage(m *models.Message) error {
|
||||
bodyTextEnc, _ := d.enc.Encrypt(m.BodyText)
|
||||
bodyHTMLEnc, _ := d.enc.Encrypt(m.BodyHTML)
|
||||
|
||||
// Build plaintext search index: subject + from name + from email + first 200 chars of body
|
||||
preview := m.BodyText
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200]
|
||||
}
|
||||
searchText := strings.ToLower(m.Subject + " " + m.FromName + " " + m.FromEmail + " " + preview)
|
||||
|
||||
res, err := d.sql.Exec(`
|
||||
INSERT INTO messages
|
||||
(account_id, folder_id, remote_uid, thread_id, message_id,
|
||||
subject, from_name, from_email, to_list, cc_list, bcc_list, reply_to,
|
||||
body_text, body_html, date, is_read, is_starred, is_draft, has_attachment)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
body_text, body_html, date, is_read, is_starred, is_draft, has_attachment, search_text)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(account_id, folder_id, remote_uid) DO UPDATE SET
|
||||
is_read=excluded.is_read,
|
||||
is_starred=excluded.is_starred`,
|
||||
is_starred=excluded.is_starred,
|
||||
has_attachment=excluded.has_attachment,
|
||||
search_text=excluded.search_text`,
|
||||
m.AccountID, m.FolderID, m.RemoteUID, m.ThreadID, m.MessageID,
|
||||
subjectEnc, fromNameEnc, fromEmailEnc, toEnc, ccEnc, bccEnc, replyToEnc,
|
||||
bodyTextEnc, bodyHTMLEnc, m.Date,
|
||||
m.IsRead, m.IsStarred, m.IsDraft, m.HasAttachment,
|
||||
m.IsRead, m.IsStarred, m.IsDraft, m.HasAttachment, searchText,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -882,15 +989,15 @@ func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page
|
||||
|
||||
func (d *DB) SearchMessages(userID int64, q string, page, pageSize int) (*models.PagedMessages, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
like := "%" + q + "%"
|
||||
args := []interface{}{userID, like, like, like, like, pageSize, offset}
|
||||
like := "%" + strings.ToLower(q) + "%"
|
||||
args := []interface{}{userID, like, pageSize, offset}
|
||||
|
||||
var total int
|
||||
d.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM messages m
|
||||
JOIN email_accounts a ON a.id=m.account_id
|
||||
WHERE a.user_id=? AND (m.subject LIKE ? OR m.from_email LIKE ? OR m.from_name LIKE ? OR m.body_text LIKE ?)`,
|
||||
userID, like, like, like, like,
|
||||
WHERE a.user_id=? AND m.search_text LIKE ?`,
|
||||
userID, like,
|
||||
).Scan(&total)
|
||||
|
||||
rows, err := d.sql.Query(`
|
||||
@@ -900,7 +1007,7 @@ func (d *DB) SearchMessages(userID int64, q string, page, pageSize int) (*models
|
||||
FROM messages m
|
||||
JOIN email_accounts a ON a.id=m.account_id
|
||||
JOIN folders f ON f.id=m.folder_id
|
||||
WHERE a.user_id=? AND (m.subject LIKE ? OR m.from_email LIKE ? OR m.from_name LIKE ? OR m.body_text LIKE ?)
|
||||
WHERE a.user_id=? AND m.search_text LIKE ?
|
||||
ORDER BY m.date DESC LIMIT ? OFFSET ?`, args...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -988,7 +1095,8 @@ func (d *DB) DeleteMessage(messageID, userID int64) error {
|
||||
|
||||
func (d *DB) GetFoldersByUser(userID int64) ([]*models.Folder, error) {
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT f.id, f.account_id, f.name, f.full_path, f.folder_type, f.unread_count, f.total_count
|
||||
SELECT f.id, f.account_id, f.name, f.full_path, f.folder_type, f.unread_count, f.total_count,
|
||||
COALESCE(f.is_hidden,0), COALESCE(f.sync_enabled,1)
|
||||
FROM folders f
|
||||
JOIN email_accounts a ON a.id=f.account_id
|
||||
WHERE a.user_id=?
|
||||
@@ -1001,9 +1109,12 @@ func (d *DB) GetFoldersByUser(userID int64) ([]*models.Folder, error) {
|
||||
var folders []*models.Folder
|
||||
for rows.Next() {
|
||||
f := &models.Folder{}
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount); err != nil {
|
||||
var isHidden, syncEnabled int
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount, &isHidden, &syncEnabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.IsHidden = isHidden == 1
|
||||
f.SyncEnabled = syncEnabled == 1
|
||||
folders = append(folders, f)
|
||||
}
|
||||
return folders, rows.Err()
|
||||
@@ -1047,14 +1158,310 @@ func (d *DB) IsRemoteContentAllowed(userID int64, sender string) (bool, error) {
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// 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 }
|
||||
_, 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=?)`,
|
||||
ih, se, folderID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountFolderMessages returns how many messages are in a folder (owned by user).
|
||||
func (d *DB) CountFolderMessages(folderID, userID int64) (int, error) {
|
||||
var count int
|
||||
err := d.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM messages m
|
||||
JOIN folders f ON f.id=m.folder_id
|
||||
JOIN email_accounts a ON a.id=f.account_id
|
||||
WHERE m.folder_id=? AND a.user_id=?`, folderID, userID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// DeleteFolder removes a folder and all its messages (cascade).
|
||||
func (d *DB) DeleteFolder(folderID, userID int64) error {
|
||||
_, err := d.sql.Exec(`
|
||||
DELETE FROM folders WHERE id=?
|
||||
AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`,
|
||||
folderID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MoveFolderContents moves all messages from one folder to another (both must belong to user).
|
||||
func (d *DB) MoveFolderContents(fromID, toID, userID int64) (int64, error) {
|
||||
res, err := d.sql.Exec(`
|
||||
UPDATE messages SET folder_id=?
|
||||
WHERE folder_id=?
|
||||
AND folder_id IN (SELECT f.id FROM folders f JOIN email_accounts a ON a.id=f.account_id WHERE a.user_id=?)`,
|
||||
toID, fromID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetFolderByID(folderID int64) (*models.Folder, error) {
|
||||
f := &models.Folder{}
|
||||
var isHidden, syncEnabled int
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count
|
||||
`SELECT id, account_id, name, full_path, folder_type, unread_count, total_count,
|
||||
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)
|
||||
).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
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return f, err
|
||||
}
|
||||
|
||||
// GetMessageIMAPInfo returns the remote_uid, folder full_path, account info needed for IMAP ops.
|
||||
func (d *DB) GetMessageIMAPInfo(messageID, userID int64) (remoteUID uint32, folderPath string, account *models.EmailAccount, err error) {
|
||||
var uidStr string
|
||||
var accountID int64
|
||||
var folderID int64
|
||||
err = d.sql.QueryRow(`
|
||||
SELECT m.remote_uid, m.account_id, m.folder_id
|
||||
FROM messages m
|
||||
JOIN email_accounts a ON a.id = m.account_id
|
||||
WHERE m.id=? AND a.user_id=?`, messageID, userID,
|
||||
).Scan(&uidStr, &accountID, &folderID)
|
||||
if err != nil {
|
||||
return 0, "", nil, err
|
||||
}
|
||||
// Parse uid
|
||||
var uid uint64
|
||||
fmt.Sscanf(uidStr, "%d", &uid)
|
||||
remoteUID = uint32(uid)
|
||||
|
||||
folder, err := d.GetFolderByID(folderID)
|
||||
if err != nil || folder == nil {
|
||||
return remoteUID, "", nil, fmt.Errorf("folder not found")
|
||||
}
|
||||
account, err = d.GetAccount(accountID)
|
||||
return remoteUID, folder.FullPath, account, err
|
||||
}
|
||||
|
||||
// ListStarredMessages returns all starred messages for a user, newest first.
|
||||
func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.PagedMessages, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
var total int
|
||||
d.sql.QueryRow(`SELECT COUNT(*) FROM messages m JOIN email_accounts a ON a.id=m.account_id WHERE a.user_id=? AND m.is_starred=1`, userID).Scan(&total)
|
||||
|
||||
rows, err := d.sql.Query(`
|
||||
SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name,
|
||||
m.subject, m.from_name, m.from_email, m.body_text,
|
||||
m.date, m.is_read, m.is_starred, m.has_attachment
|
||||
FROM messages m
|
||||
JOIN email_accounts a ON a.id = m.account_id
|
||||
JOIN folders f ON f.id = m.folder_id
|
||||
WHERE a.user_id=? AND m.is_starred=1
|
||||
ORDER BY m.date DESC
|
||||
LIMIT ? OFFSET ?`, userID, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var summaries []models.MessageSummary
|
||||
for rows.Next() {
|
||||
s := models.MessageSummary{}
|
||||
var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string
|
||||
if err := rows.Scan(
|
||||
&s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName,
|
||||
&subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc,
|
||||
&s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Subject, _ = d.enc.Decrypt(subjectEnc)
|
||||
s.FromName, _ = d.enc.Decrypt(fromNameEnc)
|
||||
s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc)
|
||||
bodyText, _ := d.enc.Decrypt(bodyTextEnc)
|
||||
if len(bodyText) > 120 {
|
||||
bodyText = bodyText[:120] + "…"
|
||||
}
|
||||
s.Preview = bodyText
|
||||
summaries = append(summaries, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.PagedMessages{
|
||||
Messages: summaries,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
HasMore: offset+len(summaries) < total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- Pending IMAP ops queue ----
|
||||
|
||||
// PendingIMAPOp represents an IMAP write operation that needs to be applied to the server.
|
||||
type PendingIMAPOp struct {
|
||||
ID int64
|
||||
AccountID int64
|
||||
OpType string // "delete" | "move" | "flag_read" | "flag_star"
|
||||
RemoteUID uint32
|
||||
FolderPath string
|
||||
Extra string // for move: dest folder path; for flag_*: "1" or "0"
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// EnqueueIMAPOp adds an operation to the pending queue atomically.
|
||||
func (d *DB) EnqueueIMAPOp(op *PendingIMAPOp) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO pending_imap_ops (account_id, op_type, remote_uid, folder_path, extra) VALUES (?,?,?,?,?)`,
|
||||
op.AccountID, op.OpType, op.RemoteUID, op.FolderPath, op.Extra,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DequeuePendingOps returns up to `limit` pending ops for a given account.
|
||||
func (d *DB) DequeuePendingOps(accountID int64, limit int) ([]*PendingIMAPOp, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, account_id, op_type, remote_uid, folder_path, extra, attempts
|
||||
FROM pending_imap_ops WHERE account_id=? ORDER BY id ASC LIMIT ?`,
|
||||
accountID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ops []*PendingIMAPOp
|
||||
for rows.Next() {
|
||||
op := &PendingIMAPOp{}
|
||||
rows.Scan(&op.ID, &op.AccountID, &op.OpType, &op.RemoteUID, &op.FolderPath, &op.Extra, &op.Attempts)
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, rows.Err()
|
||||
}
|
||||
|
||||
// DeletePendingOp removes a successfully applied op.
|
||||
func (d *DB) DeletePendingOp(id int64) error {
|
||||
_, err := d.sql.Exec(`DELETE FROM pending_imap_ops WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// IncrementPendingOpAttempts bumps attempt count; ops with >5 attempts are abandoned.
|
||||
func (d *DB) IncrementPendingOpAttempts(id int64) {
|
||||
d.sql.Exec(`UPDATE pending_imap_ops SET attempts=attempts+1 WHERE id=?`, id)
|
||||
d.sql.Exec(`DELETE FROM pending_imap_ops WHERE id=? AND attempts>5`, id)
|
||||
}
|
||||
|
||||
// CountPendingOps returns number of queued ops for an account (for logging).
|
||||
func (d *DB) CountPendingOps(accountID int64) int {
|
||||
var n int
|
||||
d.sql.QueryRow(`SELECT COUNT(*) FROM pending_imap_ops WHERE account_id=?`, accountID).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// ---- Folder delta-sync state ----
|
||||
|
||||
// GetFolderSyncState returns uid_validity and last_seen_uid for incremental sync.
|
||||
func (d *DB) GetFolderSyncState(folderID int64) (uidValidity, lastSeenUID uint32) {
|
||||
d.sql.QueryRow(`SELECT COALESCE(uid_validity,0), COALESCE(last_seen_uid,0) FROM folders WHERE id=?`, folderID).
|
||||
Scan(&uidValidity, &lastSeenUID)
|
||||
return
|
||||
}
|
||||
|
||||
// SetFolderSyncState persists uid_validity and last_seen_uid after a successful sync.
|
||||
func (d *DB) SetFolderSyncState(folderID int64, uidValidity, lastSeenUID uint32) {
|
||||
d.sql.Exec(`UPDATE folders SET uid_validity=?, last_seen_uid=? WHERE id=?`, uidValidity, lastSeenUID, folderID)
|
||||
}
|
||||
|
||||
// PurgeDeletedMessages removes local messages whose remote_uid is no longer
|
||||
// in the server's UID list for a folder. Returns count purged.
|
||||
func (d *DB) PurgeDeletedMessages(folderID int64, serverUIDs []uint32) (int, error) {
|
||||
if len(serverUIDs) == 0 {
|
||||
// Don't purge everything if server returned empty (connection issue)
|
||||
return 0, nil
|
||||
}
|
||||
// Build placeholder list
|
||||
args := make([]interface{}, len(serverUIDs)+1)
|
||||
args[0] = folderID
|
||||
placeholders := make([]string, len(serverUIDs))
|
||||
for i, uid := range serverUIDs {
|
||||
args[i+1] = fmt.Sprintf("%d", uid)
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
q := fmt.Sprintf(
|
||||
`DELETE FROM messages WHERE folder_id=? AND remote_uid NOT IN (%s)`,
|
||||
strings.Join(placeholders, ","),
|
||||
)
|
||||
res, err := d.sql.Exec(q, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// DeleteAllFolderMessages removes all messages from a folder (used on UIDVALIDITY change).
|
||||
func (d *DB) DeleteAllFolderMessages(folderID int64) {
|
||||
d.sql.Exec(`DELETE FROM messages WHERE folder_id=?`, folderID)
|
||||
}
|
||||
|
||||
// GetFolderMessageCount returns the local message count for a folder by account and path.
|
||||
func (d *DB) GetFolderMessageCount(accountID int64, folderPath string) int {
|
||||
var n int
|
||||
d.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM messages m
|
||||
JOIN folders f ON f.id=m.folder_id
|
||||
WHERE f.account_id=? AND f.full_path=?`, accountID, folderPath,
|
||||
).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// ReconcileFlags updates is_read and is_starred from server flags, but ONLY for
|
||||
// messages that do NOT have a pending local write op (to avoid overwriting in-flight changes).
|
||||
func (d *DB) ReconcileFlags(folderID int64, serverFlags map[uint32][]string) {
|
||||
// Get set of UIDs with pending ops so we don't overwrite them
|
||||
rows, _ := d.sql.Query(
|
||||
`SELECT DISTINCT remote_uid FROM pending_imap_ops po
|
||||
JOIN folders f ON f.account_id=po.account_id
|
||||
WHERE f.id=? AND (po.op_type='flag_read' OR po.op_type='flag_star')`, folderID,
|
||||
)
|
||||
pendingUIDs := make(map[uint32]bool)
|
||||
if rows != nil {
|
||||
for rows.Next() {
|
||||
var uid uint32
|
||||
rows.Scan(&uid)
|
||||
pendingUIDs[uid] = true
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
for uid, flags := range serverFlags {
|
||||
if pendingUIDs[uid] {
|
||||
continue // don't reconcile — we have a pending write for this message
|
||||
}
|
||||
isRead := false
|
||||
isStarred := false
|
||||
for _, f := range flags {
|
||||
switch f {
|
||||
case `\Seen`:
|
||||
isRead = true
|
||||
case `\Flagged`:
|
||||
isStarred = true
|
||||
}
|
||||
}
|
||||
d.sql.Exec(
|
||||
`UPDATE messages SET is_read=?, is_starred=?
|
||||
WHERE folder_id=? AND remote_uid=?`,
|
||||
boolToInt(isRead), boolToInt(isStarred),
|
||||
folderID, fmt.Sprintf("%d", uid),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -134,6 +134,97 @@ func TestConnection(account *gomailModels.EmailAccount) error {
|
||||
|
||||
func (c *Client) Close() { c.imap.Logout() }
|
||||
|
||||
func (c *Client) DeleteMailbox(name string) error {
|
||||
return c.imap.Delete(name)
|
||||
}
|
||||
|
||||
// MoveByUID copies a message to destMailbox and marks it deleted in srcMailbox.
|
||||
func (c *Client) MoveByUID(srcMailbox, destMailbox string, uid uint32) error {
|
||||
if _, err := c.imap.Select(srcMailbox, false); err != nil {
|
||||
return fmt.Errorf("select %s: %w", srcMailbox, err)
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddNum(uid)
|
||||
// COPY to destination
|
||||
if err := c.imap.UidCopy(seqSet, destMailbox); err != nil {
|
||||
return fmt.Errorf("uid copy: %w", err)
|
||||
}
|
||||
// Mark deleted in source
|
||||
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
||||
flags := []interface{}{imap.DeletedFlag}
|
||||
if err := c.imap.UidStore(seqSet, item, flags, nil); err != nil {
|
||||
return fmt.Errorf("uid store deleted: %w", err)
|
||||
}
|
||||
return c.imap.Expunge(nil)
|
||||
}
|
||||
|
||||
// DeleteByUID moves message to Trash, or hard-deletes if already in Trash.
|
||||
func (c *Client) DeleteByUID(mailboxName string, uid uint32, trashName string) error {
|
||||
if _, err := c.imap.Select(mailboxName, false); err != nil {
|
||||
return fmt.Errorf("select %s: %w", mailboxName, err)
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddNum(uid)
|
||||
isTrash := strings.EqualFold(mailboxName, trashName) || trashName == ""
|
||||
if !isTrash && trashName != "" {
|
||||
// Move to trash
|
||||
if err := c.imap.UidCopy(seqSet, trashName); err == nil {
|
||||
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
||||
_ = c.imap.UidStore(seqSet, item, []interface{}{imap.DeletedFlag}, nil)
|
||||
return c.imap.Expunge(nil)
|
||||
}
|
||||
}
|
||||
// Hard delete (already in trash or no trash folder)
|
||||
item := imap.FormatFlagsOp(imap.SetFlags, true)
|
||||
if err := c.imap.UidStore(seqSet, item, []interface{}{imap.DeletedFlag}, nil); err != nil {
|
||||
return fmt.Errorf("uid store deleted: %w", err)
|
||||
}
|
||||
return c.imap.Expunge(nil)
|
||||
}
|
||||
|
||||
// SetFlagByUID sets or clears an IMAP flag (e.g. \Seen, \Flagged) for a message.
|
||||
func (c *Client) SetFlagByUID(mailboxName string, uid uint32, flag string, set bool) error {
|
||||
if _, err := c.imap.Select(mailboxName, false); err != nil {
|
||||
return err
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddNum(uid)
|
||||
var op imap.FlagsOp
|
||||
if set {
|
||||
op = imap.AddFlags
|
||||
} else {
|
||||
op = imap.RemoveFlags
|
||||
}
|
||||
item := imap.FormatFlagsOp(op, true)
|
||||
return c.imap.UidStore(seqSet, item, []interface{}{flag}, nil)
|
||||
}
|
||||
|
||||
// FetchRawByUID returns the raw RFC 822 message bytes for the given UID.
|
||||
func (c *Client) FetchRawByUID(mailboxName string, uid uint32) ([]byte, error) {
|
||||
if _, err := c.imap.Select(mailboxName, true); err != nil {
|
||||
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddNum(uid)
|
||||
section := &imap.BodySectionName{}
|
||||
items := []imap.FetchItem{section.FetchItem()}
|
||||
ch := make(chan *imap.Message, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
|
||||
msg := <-ch
|
||||
if err := <-done; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("message not found")
|
||||
}
|
||||
body := msg.GetBody(section)
|
||||
if body == nil {
|
||||
return nil, fmt.Errorf("no body")
|
||||
}
|
||||
return io.ReadAll(body)
|
||||
}
|
||||
|
||||
func (c *Client) ListMailboxes() ([]*imap.MailboxInfo, error) {
|
||||
ch := make(chan *imap.MailboxInfo, 64)
|
||||
done := make(chan error, 1)
|
||||
@@ -145,8 +236,8 @@ func (c *Client) ListMailboxes() ([]*imap.MailboxInfo, error) {
|
||||
return result, <-done
|
||||
}
|
||||
|
||||
// FetchMessages fetches messages received within the last `days` days.
|
||||
// Falls back to the most recent 200 if the server does not support SEARCH.
|
||||
// FetchMessages fetches messages from a mailbox.
|
||||
// If days <= 0, fetches ALL messages. Otherwise fetches messages since `days` days ago.
|
||||
func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Message, error) {
|
||||
mbox, err := c.imap.Select(mailboxName, true)
|
||||
if err != nil {
|
||||
@@ -156,15 +247,22 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
criteria := imap.NewSearchCriteria()
|
||||
criteria.Since = since
|
||||
var uids []uint32
|
||||
if days <= 0 {
|
||||
// Fetch ALL messages — empty criteria matches everything
|
||||
uids, err = c.imap.UidSearch(imap.NewSearchCriteria())
|
||||
} else {
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
criteria := imap.NewSearchCriteria()
|
||||
criteria.Since = since
|
||||
uids, err = c.imap.UidSearch(criteria)
|
||||
}
|
||||
|
||||
uids, err := c.imap.Search(criteria)
|
||||
if err != nil || len(uids) == 0 {
|
||||
// Fallback: fetch last 500 by sequence number
|
||||
from := uint32(1)
|
||||
if mbox.Messages > 200 {
|
||||
from = mbox.Messages - 199
|
||||
if mbox.Messages > 500 {
|
||||
from = mbox.Messages - 499
|
||||
}
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddRange(from, mbox.Messages)
|
||||
@@ -175,7 +273,7 @@ func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Me
|
||||
for _, uid := range uids {
|
||||
seqSet.AddNum(uid)
|
||||
}
|
||||
return c.fetchBySeqSet(seqSet)
|
||||
return c.fetchByUIDSet(seqSet)
|
||||
}
|
||||
|
||||
func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
|
||||
@@ -205,6 +303,33 @@ func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, er
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// fetchByUIDSet fetches messages by UID set (used when UIDs are returned from UidSearch).
|
||||
func (c *Client) fetchByUIDSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) {
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchUid, imap.FetchEnvelope,
|
||||
imap.FetchFlags, imap.FetchBodyStructure,
|
||||
imap.FetchRFC822,
|
||||
}
|
||||
|
||||
ch := make(chan *imap.Message, 64)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
|
||||
|
||||
var results []*gomailModels.Message
|
||||
for msg := range ch {
|
||||
m, err := parseIMAPMessage(msg, c.account)
|
||||
if err != nil {
|
||||
log.Printf("parse message uid=%d: %v", msg.Uid, err)
|
||||
continue
|
||||
}
|
||||
results = append(results, m)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return results, fmt.Errorf("uid fetch: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*gomailModels.Message, error) {
|
||||
m := &gomailModels.Message{
|
||||
AccountID: account.ID,
|
||||
@@ -639,7 +764,12 @@ func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, re
|
||||
func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string {
|
||||
from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress}
|
||||
boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano())
|
||||
msgID := fmt.Sprintf("<%d.%s@gomail>", time.Now().UnixNano(), strings.ReplaceAll(account.EmailAddress, "@", "."))
|
||||
// Use the sender's actual domain for Message-ID so it passes spam filters
|
||||
domain := account.EmailAddress
|
||||
if at := strings.Index(domain, "@"); at >= 0 {
|
||||
domain = domain[at+1:]
|
||||
}
|
||||
msgID := fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), strings.ReplaceAll(account.EmailAddress, "@", "."), domain)
|
||||
|
||||
buf.WriteString("Message-ID: " + msgID + "\r\n")
|
||||
buf.WriteString("From: " + from.String() + "\r\n")
|
||||
@@ -808,3 +938,123 @@ func partPathToInts(path string) []int {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---- Delta sync helpers ----
|
||||
|
||||
// FolderStatus returns the current UIDVALIDITY, UIDNEXT, and message count
|
||||
// for a mailbox without fetching any messages.
|
||||
type FolderStatus struct {
|
||||
UIDValidity uint32
|
||||
UIDNext uint32
|
||||
Messages uint32
|
||||
}
|
||||
|
||||
func (c *Client) GetFolderStatus(mailboxName string) (*FolderStatus, error) {
|
||||
mbox, err := c.imap.Select(mailboxName, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
||||
}
|
||||
return &FolderStatus{
|
||||
UIDValidity: mbox.UidValidity,
|
||||
UIDNext: mbox.UidNext,
|
||||
Messages: mbox.Messages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAllUIDs returns all UIDs currently in the mailbox. Used for purge detection.
|
||||
func (c *Client) ListAllUIDs(mailboxName string) ([]uint32, error) {
|
||||
mbox, err := c.imap.Select(mailboxName, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
||||
}
|
||||
if mbox.Messages == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
uids, err := c.imap.UidSearch(imap.NewSearchCriteria())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uid search all: %w", err)
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
// FetchNewMessages fetches only messages with UID > afterUID (incremental).
|
||||
func (c *Client) FetchNewMessages(mailboxName string, afterUID uint32) ([]*gomailModels.Message, error) {
|
||||
mbox, err := c.imap.Select(mailboxName, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
||||
}
|
||||
if mbox.Messages == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SEARCH UID afterUID+1:*
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddRange(afterUID+1, ^uint32(0)) // afterUID+1 to * (max)
|
||||
|
||||
items := []imap.FetchItem{
|
||||
imap.FetchUid, imap.FetchEnvelope,
|
||||
imap.FetchFlags, imap.FetchBodyStructure,
|
||||
imap.FetchRFC822,
|
||||
}
|
||||
|
||||
ch := make(chan *imap.Message, 64)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.imap.UidFetch(seqSet, items, ch) }()
|
||||
|
||||
var results []*gomailModels.Message
|
||||
for msg := range ch {
|
||||
if msg.Uid <= afterUID {
|
||||
continue // skip if server returns older (shouldn't happen)
|
||||
}
|
||||
m, err := parseIMAPMessage(msg, c.account)
|
||||
if err != nil {
|
||||
log.Printf("parse message uid=%d: %v", msg.Uid, err)
|
||||
continue
|
||||
}
|
||||
results = append(results, m)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
// UID range with no results gives an error on some servers — treat as empty
|
||||
if strings.Contains(err.Error(), "No matching messages") ||
|
||||
strings.Contains(err.Error(), "BADUID") ||
|
||||
strings.Contains(err.Error(), "UID range") {
|
||||
return nil, nil
|
||||
}
|
||||
return results, fmt.Errorf("uid fetch new: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SyncFlags fetches FLAGS for all messages in a mailbox efficiently.
|
||||
// Returns map[uid]->flags for reconciliation with local state.
|
||||
func (c *Client) SyncFlags(mailboxName string) (map[uint32][]string, error) {
|
||||
mbox, err := c.imap.Select(mailboxName, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("select %s: %w", mailboxName, err)
|
||||
}
|
||||
if mbox.Messages == 0 {
|
||||
return map[uint32][]string{}, nil
|
||||
}
|
||||
|
||||
seqSet := new(imap.SeqSet)
|
||||
seqSet.AddRange(1, mbox.Messages)
|
||||
items := []imap.FetchItem{imap.FetchUid, imap.FetchFlags}
|
||||
|
||||
ch := make(chan *imap.Message, 256)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.imap.Fetch(seqSet, items, ch) }()
|
||||
|
||||
result := make(map[uint32][]string, mbox.Messages)
|
||||
for msg := range ch {
|
||||
result[msg.Uid] = msg.Flags
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
return result, fmt.Errorf("fetch flags: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SelectMailbox selects a mailbox and returns its status info.
|
||||
func (c *Client) SelectMailbox(name string) (*imap.MailboxStatus, error) {
|
||||
return c.imap.Select(name, true)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/yourusername/gomail/config"
|
||||
@@ -240,6 +242,83 @@ func (h *APIHandler) TestConnection(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// DetectMailSettings tries common IMAP/SMTP combinations for a domain and returns
|
||||
// the first working combination, or sensible defaults if nothing connects.
|
||||
func (h *APIHandler) DetectMailSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "email required")
|
||||
return
|
||||
}
|
||||
at := strings.Index(req.Email, "@")
|
||||
if at < 0 {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid email")
|
||||
return
|
||||
}
|
||||
domain := req.Email[at+1:]
|
||||
|
||||
type candidate struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
imapCandidates := []candidate{
|
||||
{"imap." + domain, 993},
|
||||
{"mail." + domain, 993},
|
||||
{"imap." + domain, 143},
|
||||
{"mail." + domain, 143},
|
||||
}
|
||||
smtpCandidates := []candidate{
|
||||
{"smtp." + domain, 587},
|
||||
{"mail." + domain, 587},
|
||||
{"smtp." + domain, 465},
|
||||
{"mail." + domain, 465},
|
||||
{"smtp." + domain, 25},
|
||||
}
|
||||
|
||||
type result struct {
|
||||
IMAPHost string `json:"imap_host"`
|
||||
IMAPPort int `json:"imap_port"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
Detected bool `json:"detected"`
|
||||
}
|
||||
|
||||
res := result{
|
||||
IMAPHost: "imap." + domain,
|
||||
IMAPPort: 993,
|
||||
SMTPHost: "smtp." + domain,
|
||||
SMTPPort: 587,
|
||||
}
|
||||
|
||||
// Try IMAP candidates (TCP dial only, no auth needed to detect)
|
||||
for _, c := range imapCandidates {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 4*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
res.IMAPHost = c.host
|
||||
res.IMAPPort = c.port
|
||||
res.Detected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Try SMTP candidates
|
||||
for _, c := range smtpCandidates {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 4*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
res.SMTPHost = c.host
|
||||
res.SMTPPort = c.port
|
||||
res.Detected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
h.writeJSON(w, res)
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
accountID := pathInt64(r, "id")
|
||||
@@ -293,6 +372,75 @@ func (h *APIHandler) SyncFolder(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true, "synced": synced})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SetFolderVisibility(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
folderID := pathInt64(r, "id")
|
||||
var req struct {
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
SyncEnabled bool `json:"sync_enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if err := h.db.SetFolderVisibility(folderID, userID, req.IsHidden, req.SyncEnabled); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) CountFolderMessages(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
folderID := pathInt64(r, "id")
|
||||
count, err := h.db.CountFolderMessages(folderID, userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "count failed")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]int{"count": count})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteFolder(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
folderID := pathInt64(r, "id")
|
||||
|
||||
// Look up folder before deleting so we have its path and account
|
||||
folder, err := h.db.GetFolderByID(folderID)
|
||||
if err != nil || folder == nil {
|
||||
h.writeError(w, http.StatusNotFound, "folder not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete on IMAP server first
|
||||
account, err := h.db.GetAccount(folder.AccountID)
|
||||
if err == nil && account != nil {
|
||||
if imapClient, cerr := email.Connect(context.Background(), account); cerr == nil {
|
||||
_ = imapClient.DeleteMailbox(folder.FullPath)
|
||||
imapClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from local DB
|
||||
if err := h.db.DeleteFolder(folderID, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) MoveFolderContents(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
fromID := pathInt64(r, "id")
|
||||
toID := pathInt64(r, "toId")
|
||||
moved, err := h.db.MoveFolderContents(fromID, toID, userID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "move failed")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"ok": true, "moved": moved})
|
||||
}
|
||||
|
||||
func (h *APIHandler) SetAccountSyncSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
accountID := pathInt64(r, "id")
|
||||
@@ -392,7 +540,21 @@ func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
messageID := pathInt64(r, "id")
|
||||
var req struct{ Read bool `json:"read"` }
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
// Update local DB first
|
||||
h.db.MarkMessageRead(messageID, userID, req.Read)
|
||||
|
||||
// Enqueue IMAP op — drained by background worker with retry
|
||||
uid, folderPath, account, err := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if err == nil && uid != 0 && account != nil {
|
||||
val := "0"
|
||||
if req.Read { val = "1" }
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_read",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
@@ -404,6 +566,16 @@ func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to toggle star")
|
||||
return
|
||||
}
|
||||
uid, folderPath, account, ierr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if ierr == nil && uid != 0 && account != nil {
|
||||
val := "0"
|
||||
if starred { val = "1" }
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "flag_star",
|
||||
RemoteUID: uid, FolderPath: folderPath, Extra: val,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true, "starred": starred})
|
||||
}
|
||||
|
||||
@@ -415,20 +587,49 @@ func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, http.StatusBadRequest, "folder_id required")
|
||||
return
|
||||
}
|
||||
|
||||
// Get IMAP info before changing folder_id in DB
|
||||
uid, srcPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
destFolder, _ := h.db.GetFolderByID(req.FolderID)
|
||||
|
||||
// Update local DB
|
||||
if err := h.db.MoveMessage(messageID, userID, req.FolderID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "move failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Enqueue IMAP move
|
||||
if imapErr == nil && uid != 0 && account != nil && destFolder != nil {
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "move",
|
||||
RemoteUID: uid, FolderPath: srcPath, Extra: destFolder.FullPath,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
messageID := pathInt64(r, "id")
|
||||
|
||||
// Get IMAP info before deleting from DB
|
||||
uid, folderPath, account, imapErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
|
||||
// Delete from local DB
|
||||
if err := h.db.DeleteMessage(messageID, userID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Enqueue IMAP delete
|
||||
if imapErr == nil && uid != 0 && account != nil {
|
||||
h.db.EnqueueIMAPOp(&db.PendingIMAPOp{
|
||||
AccountID: account.ID, OpType: "delete",
|
||||
RemoteUID: uid, FolderPath: folderPath,
|
||||
})
|
||||
h.syncer.TriggerAccountSync(account.ID)
|
||||
}
|
||||
h.writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
@@ -590,7 +791,6 @@ func (h *APIHandler) GetMessageHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
// Return a simplified set of headers we store
|
||||
headers := map[string]string{
|
||||
"Message-ID": msg.MessageID,
|
||||
"From": fmt.Sprintf("%s <%s>", msg.FromName, msg.FromEmail),
|
||||
@@ -601,7 +801,140 @@ func (h *APIHandler) GetMessageHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
"Subject": msg.Subject,
|
||||
"Date": msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"),
|
||||
}
|
||||
h.writeJSON(w, map[string]interface{}{"headers": headers})
|
||||
|
||||
// Try to fetch real raw headers from IMAP server
|
||||
rawHeaders := ""
|
||||
uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if iErr == nil && uid != 0 && account != nil {
|
||||
if c, cErr := email.Connect(context.Background(), account); cErr == nil {
|
||||
defer c.Close()
|
||||
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
|
||||
// Extract only the header section (before first blank line)
|
||||
rawStr := string(raw)
|
||||
if idx := strings.Index(rawStr, "\r\n\r\n"); idx != -1 {
|
||||
rawHeaders = rawStr[:idx+2]
|
||||
} else if idx := strings.Index(rawStr, "\n\n"); idx != -1 {
|
||||
rawHeaders = rawStr[:idx+1]
|
||||
} else {
|
||||
rawHeaders = rawStr
|
||||
}
|
||||
} else {
|
||||
log.Printf("FetchRawByUID for headers msg=%d: %v", messageID, rErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Connect for headers msg=%d: %v", messageID, cErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: reconstruct from stored fields
|
||||
if rawHeaders == "" {
|
||||
var b strings.Builder
|
||||
order := []string{"Date", "From", "To", "Cc", "Bcc", "Reply-To", "Subject", "Message-ID"}
|
||||
for _, k := range order {
|
||||
if v := headers[k]; v != "" {
|
||||
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
|
||||
}
|
||||
}
|
||||
rawHeaders = b.String()
|
||||
}
|
||||
|
||||
h.writeJSON(w, map[string]interface{}{"headers": headers, "raw": rawHeaders})
|
||||
}
|
||||
|
||||
func (h *APIHandler) StarredMessages(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 50
|
||||
}
|
||||
result, err := h.db.ListStarredMessages(userID, page, pageSize)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list starred")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (h *APIHandler) DownloadEML(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r)
|
||||
messageID := pathInt64(r, "id")
|
||||
msg, err := h.db.GetMessage(messageID, userID)
|
||||
if err != nil || msg == nil {
|
||||
h.writeError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Try to fetch raw from IMAP first
|
||||
uid, folderPath, account, iErr := h.db.GetMessageIMAPInfo(messageID, userID)
|
||||
if iErr == nil && uid != 0 && account != nil {
|
||||
if c, cErr := email.Connect(context.Background(), account); cErr == nil {
|
||||
defer c.Close()
|
||||
if raw, rErr := c.FetchRawByUID(folderPath, uid); rErr == nil {
|
||||
safe := sanitizeFilename(msg.Subject) + ".eml"
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe))
|
||||
w.Header().Set("Content-Type", "message/rfc822")
|
||||
w.Write(raw)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: reconstruct from stored fields
|
||||
var buf strings.Builder
|
||||
buf.WriteString("Date: " + msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n")
|
||||
buf.WriteString(fmt.Sprintf("From: %s <%s>\r\n", msg.FromName, msg.FromEmail))
|
||||
if msg.ToList != "" {
|
||||
buf.WriteString("To: " + msg.ToList + "\r\n")
|
||||
}
|
||||
if msg.CCList != "" {
|
||||
buf.WriteString("Cc: " + msg.CCList + "\r\n")
|
||||
}
|
||||
buf.WriteString("Subject: " + msg.Subject + "\r\n")
|
||||
if msg.MessageID != "" {
|
||||
buf.WriteString("Message-ID: " + msg.MessageID + "\r\n")
|
||||
}
|
||||
buf.WriteString("MIME-Version: 1.0\r\n")
|
||||
if msg.BodyHTML != "" {
|
||||
boundary := "GoMailBoundary"
|
||||
buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n")
|
||||
buf.WriteString("--" + boundary + "\r\n")
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.BodyText + "\r\n")
|
||||
buf.WriteString("--" + boundary + "\r\n")
|
||||
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.BodyHTML + "\r\n")
|
||||
buf.WriteString("--" + boundary + "--\r\n")
|
||||
} else {
|
||||
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
buf.WriteString(msg.BodyText)
|
||||
}
|
||||
safe := sanitizeFilename(msg.Subject) + ".eml"
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe))
|
||||
w.Header().Set("Content-Type", "message/rfc822")
|
||||
w.Write([]byte(buf.String()))
|
||||
}
|
||||
|
||||
func sanitizeFilename(s string) string {
|
||||
var out strings.Builder
|
||||
for _, r := range s {
|
||||
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
|
||||
out.WriteRune('_')
|
||||
} else {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
result := strings.TrimSpace(out.String())
|
||||
if result == "" {
|
||||
return "message"
|
||||
}
|
||||
if len(result) > 80 {
|
||||
result = result[:80]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---- Remote content whitelist ----
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -17,9 +18,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
totpDigits = 6
|
||||
totpPeriod = 30 // seconds
|
||||
totpWindow = 1 // accept ±1 period to allow for clock skew
|
||||
totpDigits = 6
|
||||
totpPeriod = 30 // seconds
|
||||
totpWindow = 2 // accept ±2 periods (±60s) to handle clock skew and slow input
|
||||
)
|
||||
|
||||
// GenerateSecret creates a new random 20-byte (160-bit) TOTP secret,
|
||||
@@ -58,15 +59,19 @@ func QRCodeURL(issuer, accountName, secret string) string {
|
||||
|
||||
// Validate checks whether code is a valid TOTP code for secret at the current time.
|
||||
// It accepts codes from [now-window*period, now+window*period] to handle clock skew.
|
||||
// Handles both padded and unpadded base32 secrets.
|
||||
func Validate(secret, code string) bool {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) != totpDigits {
|
||||
return false
|
||||
}
|
||||
keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(
|
||||
strings.ToUpper(secret),
|
||||
)
|
||||
// Normalise: uppercase, strip spaces and padding, then re-decode.
|
||||
// Accept both padded (JBSWY3DP====) and unpadded (JBSWY3DP) base32.
|
||||
cleaned := strings.ToUpper(strings.ReplaceAll(secret, " ", ""))
|
||||
cleaned = strings.TrimRight(cleaned, "=")
|
||||
keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(cleaned)
|
||||
if err != nil {
|
||||
log.Printf("mfa: base32 decode error (secret len=%d): %v", len(secret), err)
|
||||
return false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
|
||||
@@ -47,7 +47,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://api.qrserver.com;")
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src * data: blob:; frame-src 'self' blob:;")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,6 +125,8 @@ type Folder struct {
|
||||
FolderType string `json:"folder_type"` // inbox, sent, drafts, trash, spam, custom
|
||||
UnreadCount int `json:"unread_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
SyncEnabled bool `json:"sync_enabled"`
|
||||
}
|
||||
|
||||
// ---- Messages ----
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// Package syncer provides background IMAP synchronisation for all active accounts.
|
||||
// Architecture:
|
||||
// - One goroutine per account runs IDLE on the INBOX to receive push notifications.
|
||||
// - A separate drain goroutine flushes pending_imap_ops (delete/move/flag writes).
|
||||
// - Periodic full-folder delta sync catches changes made by other clients.
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yourusername/gomail/internal/db"
|
||||
@@ -12,68 +17,536 @@ import (
|
||||
"github.com/yourusername/gomail/internal/models"
|
||||
)
|
||||
|
||||
// Scheduler runs background sync for all active accounts according to their
|
||||
// individual sync_interval settings.
|
||||
// Scheduler coordinates all background sync activity.
|
||||
type Scheduler struct {
|
||||
db *db.DB
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// push channels: accountID -> channel to signal "something changed on server"
|
||||
pushMu sync.Mutex
|
||||
pushCh map[int64]chan struct{}
|
||||
}
|
||||
|
||||
// New creates a new Scheduler. Call Start() to begin background syncing.
|
||||
// New creates a new Scheduler.
|
||||
func New(database *db.DB) *Scheduler {
|
||||
return &Scheduler{db: database, stop: make(chan struct{})}
|
||||
return &Scheduler{
|
||||
db: database,
|
||||
stop: make(chan struct{}),
|
||||
pushCh: make(map[int64]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the scheduler goroutine. Ticks every minute and checks
|
||||
// which accounts are due for sync based on last_sync and sync_interval.
|
||||
// Start launches all background goroutines.
|
||||
func (s *Scheduler) Start() {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
log.Println("Background sync scheduler started")
|
||||
s.runDue()
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.runDue()
|
||||
case <-s.stop:
|
||||
log.Println("Background sync scheduler stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
defer s.wg.Done()
|
||||
s.mainLoop()
|
||||
}()
|
||||
log.Println("[sync] scheduler started")
|
||||
}
|
||||
|
||||
// Stop signals the scheduler to exit.
|
||||
// Stop signals all goroutines to exit and waits for them.
|
||||
func (s *Scheduler) Stop() {
|
||||
close(s.stop)
|
||||
s.wg.Wait()
|
||||
log.Println("[sync] scheduler stopped")
|
||||
}
|
||||
|
||||
func (s *Scheduler) runDue() {
|
||||
// TriggerAccountSync signals an immediate sync for an account (called after IMAP write ops).
|
||||
func (s *Scheduler) TriggerAccountSync(accountID int64) {
|
||||
s.pushMu.Lock()
|
||||
ch, ok := s.pushCh[accountID]
|
||||
s.pushMu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default: // already pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Main coordination loop ----
|
||||
|
||||
func (s *Scheduler) mainLoop() {
|
||||
// Ticker for the outer "check which accounts are due" loop.
|
||||
// Runs every 30s; individual accounts control their own interval.
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Track per-account goroutines so we only launch one per account.
|
||||
type accountWorker struct {
|
||||
stop chan struct{}
|
||||
pushCh chan struct{}
|
||||
}
|
||||
workers := make(map[int64]*accountWorker)
|
||||
|
||||
spawnWorker := func(account *models.EmailAccount) {
|
||||
if _, exists := workers[account.ID]; exists {
|
||||
return
|
||||
}
|
||||
w := &accountWorker{
|
||||
stop: make(chan struct{}),
|
||||
pushCh: make(chan struct{}, 1),
|
||||
}
|
||||
workers[account.ID] = w
|
||||
|
||||
s.pushMu.Lock()
|
||||
s.pushCh[account.ID] = w.pushCh
|
||||
s.pushMu.Unlock()
|
||||
|
||||
s.wg.Add(1)
|
||||
go func(acc *models.EmailAccount, w *accountWorker) {
|
||||
defer s.wg.Done()
|
||||
s.accountWorker(acc, w.stop, w.pushCh)
|
||||
}(account, w)
|
||||
}
|
||||
|
||||
stopWorker := func(accountID int64) {
|
||||
if w, ok := workers[accountID]; ok {
|
||||
close(w.stop)
|
||||
delete(workers, accountID)
|
||||
s.pushMu.Lock()
|
||||
delete(s.pushCh, accountID)
|
||||
s.pushMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial spawn
|
||||
s.spawnForActive(spawnWorker)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
for id := range workers {
|
||||
stopWorker(id)
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Build active IDs map for reconciliation
|
||||
activeIDs := make(map[int64]bool, len(workers))
|
||||
for id := range workers {
|
||||
activeIDs[id] = true
|
||||
}
|
||||
s.reconcileWorkers(activeIDs, spawnWorker, stopWorker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) spawnForActive(spawn func(*models.EmailAccount)) {
|
||||
accounts, err := s.db.ListAllActiveAccounts()
|
||||
if err != nil {
|
||||
log.Printf("Sync scheduler: list accounts: %v", err)
|
||||
log.Printf("[sync] list accounts: %v", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, account := range accounts {
|
||||
if account.SyncInterval <= 0 {
|
||||
continue
|
||||
for _, acc := range accounts {
|
||||
spawn(acc)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) reconcileWorkers(
|
||||
activeIDs map[int64]bool,
|
||||
spawn func(*models.EmailAccount),
|
||||
stop func(int64),
|
||||
) {
|
||||
accounts, err := s.db.ListAllActiveAccounts()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
serverActive := make(map[int64]bool)
|
||||
for _, acc := range accounts {
|
||||
serverActive[acc.ID] = true
|
||||
if !activeIDs[acc.ID] {
|
||||
spawn(acc)
|
||||
}
|
||||
nextSync := account.LastSync.Add(time.Duration(account.SyncInterval) * time.Minute)
|
||||
if account.LastSync.IsZero() || now.After(nextSync) {
|
||||
go s.syncAccount(account)
|
||||
}
|
||||
for id := range activeIDs {
|
||||
if !serverActive[id] {
|
||||
stop(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SyncAccountNow performs an immediate sync of one account. Returns messages synced.
|
||||
// ---- Per-account worker ----
|
||||
// Each worker:
|
||||
// 1. On startup: drain pending ops, then do a full delta sync.
|
||||
// 2. Runs an IDLE loop on INBOX for push notifications.
|
||||
// 3. Every syncInterval minutes (or on push signal): delta sync all enabled folders.
|
||||
// 4. Every 2 minutes: drain pending ops (retries failed writes).
|
||||
|
||||
func (s *Scheduler) accountWorker(account *models.EmailAccount, stop chan struct{}, push chan struct{}) {
|
||||
log.Printf("[sync] worker started for %s", account.EmailAddress)
|
||||
|
||||
// Fresh account data function (interval can change at runtime)
|
||||
getAccount := func() *models.EmailAccount {
|
||||
a, _ := s.db.GetAccount(account.ID)
|
||||
if a == nil {
|
||||
return account
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Initial sync on startup
|
||||
s.drainPendingOps(account)
|
||||
s.deltaSync(getAccount())
|
||||
|
||||
// Drain ticker: retry pending ops every 90 seconds
|
||||
drainTicker := time.NewTicker(90 * time.Second)
|
||||
defer drainTicker.Stop()
|
||||
|
||||
// Full sync ticker: based on account sync_interval, check every 30s
|
||||
syncTicker := time.NewTicker(30 * time.Second)
|
||||
defer syncTicker.Stop()
|
||||
|
||||
// IDLE watcher for INBOX push notifications
|
||||
idleCh := make(chan struct{}, 1)
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.idleWatcher(account, stop, idleCh)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
log.Printf("[sync] worker stopped for %s", account.EmailAddress)
|
||||
return
|
||||
case <-drainTicker.C:
|
||||
s.drainPendingOps(getAccount())
|
||||
case <-idleCh:
|
||||
// Server signalled new mail/changes in INBOX — sync just INBOX
|
||||
acc := getAccount()
|
||||
s.syncInbox(acc)
|
||||
case <-push:
|
||||
// Local trigger (after write op) — drain ops then sync
|
||||
acc := getAccount()
|
||||
s.drainPendingOps(acc)
|
||||
s.deltaSync(acc)
|
||||
case <-syncTicker.C:
|
||||
acc := getAccount()
|
||||
if acc.SyncInterval <= 0 {
|
||||
continue
|
||||
}
|
||||
nextSync := acc.LastSync.Add(time.Duration(acc.SyncInterval) * time.Minute)
|
||||
if acc.LastSync.IsZero() || time.Now().After(nextSync) {
|
||||
s.deltaSync(acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IDLE watcher ----
|
||||
// Maintains a persistent IMAP connection to INBOX and issues IDLE.
|
||||
// When EXISTS or EXPUNGE arrives, sends to idleCh.
|
||||
func (s *Scheduler) idleWatcher(account *models.EmailAccount, stop chan struct{}, idleCh chan struct{}) {
|
||||
const reconnectDelay = 30 * time.Second
|
||||
const idleTimeout = 25 * time.Minute // RFC 2177 recommends < 29min
|
||||
|
||||
signal := func() {
|
||||
select {
|
||||
case idleCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
c, err := email.Connect(ctx, account)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("[idle:%s] connect: %v — retry in %s", account.EmailAddress, err, reconnectDelay)
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(reconnectDelay):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Select INBOX
|
||||
_, err = c.SelectMailbox("INBOX")
|
||||
if err != nil {
|
||||
c.Close()
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(reconnectDelay):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// IDLE loop — go-imap v1 does not have built-in IDLE, we poll with short
|
||||
// CHECK + NOOP and rely on the EXISTS response to wake us.
|
||||
// We use a 1-minute poll since go-imap v1 doesn't expose IDLE directly.
|
||||
pollTicker := time.NewTicker(60 * time.Second)
|
||||
idleTimer := time.NewTimer(idleTimeout)
|
||||
|
||||
pollLoop:
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
pollTicker.Stop()
|
||||
idleTimer.Stop()
|
||||
c.Close()
|
||||
return
|
||||
case <-idleTimer.C:
|
||||
// Reconnect to keep connection alive
|
||||
pollTicker.Stop()
|
||||
c.Close()
|
||||
break pollLoop
|
||||
case <-pollTicker.C:
|
||||
// Poll server for changes
|
||||
status, err := c.GetFolderStatus("INBOX")
|
||||
if err != nil {
|
||||
log.Printf("[idle:%s] status check: %v", account.EmailAddress, err)
|
||||
pollTicker.Stop()
|
||||
idleTimer.Stop()
|
||||
c.Close()
|
||||
break pollLoop
|
||||
}
|
||||
// Check if message count changed
|
||||
localCount := s.db.GetFolderMessageCount(account.ID, "INBOX")
|
||||
if status.Messages != uint32(localCount) {
|
||||
signal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Delta sync ----
|
||||
// For each enabled folder:
|
||||
// 1. Check UIDVALIDITY — if changed, full re-sync (folder was recreated on server).
|
||||
// 2. Fetch only new messages (UID > last_seen_uid).
|
||||
// 3. Fetch FLAGS for all existing messages to catch read/star changes from other clients.
|
||||
// 4. Fetch all server UIDs and purge locally deleted messages.
|
||||
|
||||
func (s *Scheduler) deltaSync(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
log.Printf("[sync:%s] connect: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, err.Error())
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
s.db.ClearAccountError(account.ID)
|
||||
|
||||
mailboxes, err := c.ListMailboxes()
|
||||
if err != nil {
|
||||
log.Printf("[sync:%s] list mailboxes: %v", account.EmailAddress, err)
|
||||
return
|
||||
}
|
||||
|
||||
totalNew := 0
|
||||
for _, mb := range mailboxes {
|
||||
folderType := email.InferFolderType(mb.Name, mb.Attributes)
|
||||
folder := &models.Folder{
|
||||
AccountID: account.ID,
|
||||
Name: mb.Name,
|
||||
FullPath: mb.Name,
|
||||
FolderType: folderType,
|
||||
}
|
||||
if err := s.db.UpsertFolder(folder); err != nil {
|
||||
continue
|
||||
}
|
||||
dbFolder, _ := s.db.GetFolderByPath(account.ID, mb.Name)
|
||||
if dbFolder == nil || !dbFolder.SyncEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := s.syncFolder(c, account, dbFolder)
|
||||
if err != nil {
|
||||
log.Printf("[sync:%s] folder %s: %v", account.EmailAddress, mb.Name, err)
|
||||
continue
|
||||
}
|
||||
totalNew += n
|
||||
}
|
||||
|
||||
s.db.UpdateAccountLastSync(account.ID)
|
||||
if totalNew > 0 {
|
||||
log.Printf("[sync:%s] %d new messages", account.EmailAddress, totalNew)
|
||||
}
|
||||
}
|
||||
|
||||
// syncInbox is a fast path that only syncs the INBOX folder.
|
||||
func (s *Scheduler) syncInbox(account *models.EmailAccount) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
dbFolder, _ := s.db.GetFolderByPath(account.ID, "INBOX")
|
||||
if dbFolder == nil {
|
||||
return
|
||||
}
|
||||
n, err := s.syncFolder(c, account, dbFolder)
|
||||
if err != nil {
|
||||
log.Printf("[idle:%s] INBOX sync: %v", account.EmailAddress, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[idle:%s] %d new messages in INBOX", account.EmailAddress, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncFolder(c *email.Client, account *models.EmailAccount, dbFolder *models.Folder) (int, error) {
|
||||
status, err := c.GetFolderStatus(dbFolder.FullPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("status: %w", err)
|
||||
}
|
||||
|
||||
storedValidity, lastSeenUID := s.db.GetFolderSyncState(dbFolder.ID)
|
||||
newMessages := 0
|
||||
|
||||
// UIDVALIDITY changed = folder was recreated on server; wipe local and re-fetch all
|
||||
if storedValidity != 0 && status.UIDValidity != storedValidity {
|
||||
log.Printf("[sync] UIDVALIDITY changed for %s/%s — full re-sync", account.EmailAddress, dbFolder.FullPath)
|
||||
s.db.DeleteAllFolderMessages(dbFolder.ID)
|
||||
lastSeenUID = 0
|
||||
}
|
||||
|
||||
// 1. Fetch new messages (UID > lastSeenUID)
|
||||
var msgs []*models.Message
|
||||
if lastSeenUID == 0 {
|
||||
// First sync: respect the account's days/all setting
|
||||
days := account.SyncDays
|
||||
if days <= 0 || account.SyncMode == "all" {
|
||||
days = 0
|
||||
}
|
||||
msgs, err = c.FetchMessages(dbFolder.FullPath, days)
|
||||
} else {
|
||||
msgs, err = c.FetchNewMessages(dbFolder.FullPath, lastSeenUID)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("fetch new: %w", err)
|
||||
}
|
||||
|
||||
maxUID := lastSeenUID
|
||||
for _, msg := range msgs {
|
||||
msg.FolderID = dbFolder.ID
|
||||
if err := s.db.UpsertMessage(msg); err == nil {
|
||||
newMessages++
|
||||
}
|
||||
uid := uint32(0)
|
||||
fmt.Sscanf(msg.RemoteUID, "%d", &uid)
|
||||
if uid > maxUID {
|
||||
maxUID = uid
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync flags for ALL existing messages (catch read/star changes from other clients)
|
||||
flags, err := c.SyncFlags(dbFolder.FullPath)
|
||||
if err != nil {
|
||||
log.Printf("[sync] flags %s/%s: %v", account.EmailAddress, dbFolder.FullPath, err)
|
||||
} else if len(flags) > 0 {
|
||||
s.db.ReconcileFlags(dbFolder.ID, flags)
|
||||
}
|
||||
|
||||
// 3. Fetch all server UIDs and purge messages deleted on server
|
||||
serverUIDs, err := c.ListAllUIDs(dbFolder.FullPath)
|
||||
if err != nil {
|
||||
log.Printf("[sync] list uids %s/%s: %v", account.EmailAddress, dbFolder.FullPath, err)
|
||||
} else {
|
||||
purged, _ := s.db.PurgeDeletedMessages(dbFolder.ID, serverUIDs)
|
||||
if purged > 0 {
|
||||
log.Printf("[sync] purged %d server-deleted messages from %s/%s", purged, account.EmailAddress, dbFolder.FullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Save sync state
|
||||
s.db.SetFolderSyncState(dbFolder.ID, status.UIDValidity, maxUID)
|
||||
s.db.UpdateFolderCounts(dbFolder.ID)
|
||||
|
||||
return newMessages, nil
|
||||
}
|
||||
|
||||
// ---- Pending ops drain ----
|
||||
// Applies queued IMAP write operations (delete/move/flag) with retry logic.
|
||||
|
||||
func (s *Scheduler) drainPendingOps(account *models.EmailAccount) {
|
||||
ops, err := s.db.DequeuePendingOps(account.ID, 50)
|
||||
if err != nil || len(ops) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
log.Printf("[ops:%s] connect for drain: %v", account.EmailAddress, err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Find trash folder name once
|
||||
trashName := ""
|
||||
if mboxes, err := c.ListMailboxes(); err == nil {
|
||||
for _, mb := range mboxes {
|
||||
if email.InferFolderType(mb.Name, mb.Attributes) == "trash" {
|
||||
trashName = mb.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
var applyErr error
|
||||
switch op.OpType {
|
||||
case "delete":
|
||||
applyErr = c.DeleteByUID(op.FolderPath, op.RemoteUID, trashName)
|
||||
case "move":
|
||||
applyErr = c.MoveByUID(op.FolderPath, op.Extra, op.RemoteUID)
|
||||
case "flag_read":
|
||||
applyErr = c.SetFlagByUID(op.FolderPath, op.RemoteUID, `\Seen`, op.Extra == "1")
|
||||
case "flag_star":
|
||||
applyErr = c.SetFlagByUID(op.FolderPath, op.RemoteUID, `\Flagged`, op.Extra == "1")
|
||||
}
|
||||
|
||||
if applyErr != nil {
|
||||
log.Printf("[ops:%s] %s uid=%d folder=%s: %v", account.EmailAddress, op.OpType, op.RemoteUID, op.FolderPath, applyErr)
|
||||
s.db.IncrementPendingOpAttempts(op.ID)
|
||||
} else {
|
||||
s.db.DeletePendingOp(op.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if n := s.db.CountPendingOps(account.ID); n > 0 {
|
||||
log.Printf("[ops:%s] %d ops still pending after drain", account.EmailAddress, n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Public API (called by HTTP handlers) ----
|
||||
|
||||
// SyncAccountNow performs an immediate delta sync of one account.
|
||||
func (s *Scheduler) SyncAccountNow(accountID int64) (int, error) {
|
||||
account, err := s.db.GetAccount(accountID)
|
||||
if err != nil || account == nil {
|
||||
return 0, fmt.Errorf("account %d not found", accountID)
|
||||
}
|
||||
return s.doSync(account)
|
||||
s.drainPendingOps(account)
|
||||
s.deltaSync(account)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// SyncFolderNow syncs a single folder for an account.
|
||||
@@ -86,6 +559,7 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
||||
if err != nil || folder == nil || folder.AccountID != accountID {
|
||||
return 0, fmt.Errorf("folder %d not found", folderID)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
c, err := email.Connect(ctx, account)
|
||||
@@ -93,96 +567,8 @@ func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
defer c.Close()
|
||||
days := account.SyncDays
|
||||
if days <= 0 || account.SyncMode == "all" {
|
||||
days = 36500 // ~100 years = full mailbox
|
||||
}
|
||||
messages, err := c.FetchMessages(folder.FullPath, days)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
synced := 0
|
||||
for _, msg := range messages {
|
||||
msg.FolderID = folder.ID
|
||||
if err := s.db.UpsertMessage(msg); err == nil {
|
||||
synced++
|
||||
}
|
||||
}
|
||||
s.db.UpdateFolderCounts(folder.ID)
|
||||
s.db.UpdateAccountLastSync(accountID)
|
||||
return synced, nil
|
||||
|
||||
return s.syncFolder(c, account, folder)
|
||||
}
|
||||
|
||||
func (s *Scheduler) syncAccount(account *models.EmailAccount) {
|
||||
synced, err := s.doSync(account)
|
||||
if err != nil {
|
||||
log.Printf("Sync [%s]: %v", account.EmailAddress, err)
|
||||
s.db.SetAccountError(account.ID, err.Error())
|
||||
s.db.WriteAudit(nil, models.AuditAppError,
|
||||
"sync error for "+account.EmailAddress+": "+err.Error(), "", "")
|
||||
return
|
||||
}
|
||||
s.db.ClearAccountError(account.ID)
|
||||
if synced > 0 {
|
||||
log.Printf("Synced %d messages for %s", synced, account.EmailAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) doSync(account *models.EmailAccount) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
c, err := email.Connect(ctx, account)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
mailboxes, err := c.ListMailboxes()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list mailboxes: %w", err)
|
||||
}
|
||||
|
||||
synced := 0
|
||||
for _, mb := range mailboxes {
|
||||
folderType := email.InferFolderType(mb.Name, mb.Attributes)
|
||||
|
||||
folder := &models.Folder{
|
||||
AccountID: account.ID,
|
||||
Name: mb.Name,
|
||||
FullPath: mb.Name,
|
||||
FolderType: folderType,
|
||||
}
|
||||
if err := s.db.UpsertFolder(folder); err != nil {
|
||||
log.Printf("Upsert folder %s: %v", mb.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
dbFolder, _ := s.db.GetFolderByPath(account.ID, mb.Name)
|
||||
if dbFolder == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
days := account.SyncDays
|
||||
if days <= 0 || account.SyncMode == "all" {
|
||||
days = 36500 // ~100 years = full mailbox
|
||||
}
|
||||
messages, err := c.FetchMessages(mb.Name, days)
|
||||
if err != nil {
|
||||
log.Printf("Fetch %s/%s: %v", account.EmailAddress, mb.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
msg.FolderID = dbFolder.ID
|
||||
if err := s.db.UpsertMessage(msg); err == nil {
|
||||
synced++
|
||||
}
|
||||
}
|
||||
|
||||
s.db.UpdateFolderCounts(dbFolder.ID)
|
||||
}
|
||||
|
||||
s.db.UpdateAccountLastSync(account.ID)
|
||||
return synced, nil
|
||||
}
|
||||
|
||||
@@ -153,18 +153,8 @@ body.app-page{overflow:hidden}
|
||||
.compose-btn{padding:6px 12px;background:var(--accent);border:none;border-radius:6px;
|
||||
color:white;font-family:'DM Sans',sans-serif;font-size:12px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.compose-btn:hover{opacity:.85}
|
||||
.accounts-section{padding:10px 8px 4px;border-bottom:1px solid var(--border)}
|
||||
.section-label{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
||||
color:var(--muted);padding:0 6px 6px}
|
||||
.account-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;
|
||||
cursor:pointer;transition:background .1s;position:relative}
|
||||
.account-item:hover{background:var(--surface3)}
|
||||
/* ── Account dot (still used in popup) */
|
||||
.account-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.account-email{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.account-error-dot{width:6px;height:6px;background:var(--danger);border-radius:50%;flex-shrink:0}
|
||||
.add-account-btn{display:flex;align-items:center;gap:6px;padding:5px 6px;color:var(--accent);
|
||||
font-size:12px;cursor:pointer;border-radius:6px;transition:background .1s;margin-top:2px}
|
||||
.add-account-btn:hover{background:var(--accent-dim)}
|
||||
.nav-section{padding:4px 8px;flex:1;overflow-y:auto}
|
||||
.nav-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;
|
||||
cursor:pointer;transition:background .1s;color:var(--text2);user-select:none;font-size:13px}
|
||||
@@ -201,9 +191,14 @@ body.app-page{overflow:hidden}
|
||||
.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
|
||||
.message-item:hover{background:var(--surface2)}
|
||||
.message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px}
|
||||
.message-item.unread .msg-subject{font-weight:500;color:var(--text)}
|
||||
.message-item.unread::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);
|
||||
width:3px;height:22px;background:var(--accent);border-radius:0 2px 2px 0}
|
||||
/* Unread: lighter background + bold sender so it pops clearly */
|
||||
.message-item.unread{background:rgba(255,255,255,.035)}
|
||||
.message-item.unread:hover{background:rgba(255,255,255,.055)}
|
||||
.message-item.unread .msg-from{color:var(--text);font-weight:600}
|
||||
.message-item.unread .msg-subject{font-weight:600;color:var(--text)}
|
||||
.message-item.unread::before{content:'';position:absolute;left:0;top:0;bottom:0;
|
||||
width:3px;background:var(--accent);border-radius:0 2px 2px 0}
|
||||
.message-item.unread.active{background:var(--accent-dim)}
|
||||
.message-item.unread.active::before{display:none}
|
||||
.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
|
||||
.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
@@ -247,30 +242,60 @@ body.app-page{overflow:hidden}
|
||||
.detail-body-text{font-size:13px;line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word}
|
||||
.detail-body iframe{width:100%;border:none;min-height:400px}
|
||||
|
||||
/* Compose */
|
||||
.compose-overlay{position:fixed;bottom:20px;right:24px;z-index:50;display:none}
|
||||
.compose-overlay.open{display:block}
|
||||
.compose-window{width:540px;background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.6);display:flex;flex-direction:column}
|
||||
.compose-header{padding:12px 16px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between}
|
||||
.compose-title{font-size:14px;font-weight:500}
|
||||
.compose-close{background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;
|
||||
line-height:1;padding:2px 6px;border-radius:4px}
|
||||
/* ── Compose dialog (draggable, all-edge resizable) ──────────── */
|
||||
.compose-dialog{
|
||||
position:fixed;bottom:20px;right:24px;
|
||||
width:540px;height:480px;
|
||||
background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 24px 64px rgba(0,0,0,.65);
|
||||
display:none;flex-direction:column;z-index:200;
|
||||
min-width:360px;min-height:280px;overflow:hidden;
|
||||
user-select:none;
|
||||
}
|
||||
.compose-dialog-header{
|
||||
padding:10px 12px 10px 16px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
cursor:grab;flex-shrink:0;background:var(--surface2);
|
||||
}
|
||||
.compose-dialog-header:active{cursor:grabbing}
|
||||
.compose-body-wrap{display:flex;flex-direction:column;flex:1;overflow:hidden;min-height:0}
|
||||
.compose-title{font-size:13px;font-weight:500;pointer-events:none}
|
||||
.compose-close{background:none;border:none;color:var(--muted);font-size:17px;cursor:pointer;
|
||||
line-height:1;padding:2px 5px;border-radius:4px;pointer-events:all}
|
||||
.compose-close:hover{background:var(--surface3);color:var(--text)}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 14px;gap:10px}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:6px 14px;gap:10px;flex-shrink:0}
|
||||
.compose-field label{font-size:12px;color:var(--muted);width:44px;flex-shrink:0}
|
||||
.compose-field input,.compose-field select{flex:1;background:none;border:none;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;outline:none}
|
||||
.compose-field select option{background:var(--surface2)}
|
||||
.compose-body textarea{width:100%;height:200px;background:none;border:none;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;line-height:1.6;resize:none;outline:none;padding:12px 14px}
|
||||
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px}
|
||||
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.send-btn{padding:7px 20px;background:var(--accent);border:none;border-radius:6px;color:white;
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.send-btn:hover{opacity:.85}
|
||||
.send-btn:disabled{opacity:.5;cursor:default}
|
||||
|
||||
/* Resize handles — 8-directional */
|
||||
.compose-resize{position:absolute;z-index:10}
|
||||
.compose-resize[data-dir="e"] {right:0;top:8px;bottom:8px;width:5px;cursor:e-resize}
|
||||
.compose-resize[data-dir="w"] {left:0;top:8px;bottom:8px;width:5px;cursor:w-resize}
|
||||
.compose-resize[data-dir="s"] {bottom:0;left:8px;right:8px;height:5px;cursor:s-resize}
|
||||
.compose-resize[data-dir="n"] {top:0;left:8px;right:8px;height:5px;cursor:n-resize}
|
||||
.compose-resize[data-dir="se"]{right:0;bottom:0;width:12px;height:12px;cursor:se-resize}
|
||||
.compose-resize[data-dir="sw"]{left:0;bottom:0;width:12px;height:12px;cursor:sw-resize}
|
||||
.compose-resize[data-dir="ne"]{right:0;top:0;width:12px;height:12px;cursor:ne-resize}
|
||||
.compose-resize[data-dir="nw"]{left:0;top:0;width:12px;height:12px;cursor:nw-resize}
|
||||
|
||||
/* Minimised pill */
|
||||
.compose-minimised{
|
||||
position:fixed;bottom:0;right:24px;z-index:201;
|
||||
display:none;align-items:center;gap:8px;
|
||||
padding:8px 16px;background:var(--surface2);
|
||||
border:1px solid var(--border2);border-bottom:none;
|
||||
border-radius:8px 8px 0 0;font-size:13px;cursor:pointer;
|
||||
box-shadow:0 -4px 20px rgba(0,0,0,.3);
|
||||
}
|
||||
.compose-minimised:hover{background:var(--surface3)}
|
||||
|
||||
/* Provider buttons */
|
||||
.provider-btns{display:flex;gap:10px;margin-bottom:14px}
|
||||
.provider-btn{flex:1;padding:10px;background:var(--surface3);border:1px solid var(--border2);
|
||||
@@ -324,8 +349,8 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
.fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s}
|
||||
.fmt-btn:hover{background:var(--border2);color:var(--text)}
|
||||
.fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px}
|
||||
.compose-editor{flex:1;min-height:160px;max-height:320px;overflow-y:auto;padding:12px 14px;
|
||||
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg)}
|
||||
.compose-editor{flex:1;overflow-y:auto;padding:12px 14px;
|
||||
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg);min-height:0}
|
||||
.compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none}
|
||||
.compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)}
|
||||
.compose-editor .quote-divider{font-size:11px;color:var(--muted);margin:10px 0 4px}
|
||||
@@ -350,29 +375,92 @@ body.admin-page{overflow:auto;background:var(--bg)}
|
||||
|
||||
/* ── Email tag input ─────────────────────────────────────────── */
|
||||
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
||||
padding:4px 6px;min-height:34px;cursor:text;background:var(--bg);
|
||||
border:1px solid var(--border);border-radius:6px}
|
||||
.tag-container:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px rgba(99,102,241,.15)}
|
||||
.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:8px}
|
||||
.email-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 6px 2px 8px;
|
||||
padding:4px 6px;min-height:32px;cursor:text;background:transparent}
|
||||
.tag-container:focus-within{}
|
||||
.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:7px}
|
||||
.email-tag{display:inline-flex;align-items:center;gap:3px;padding:2px 6px 2px 8px;
|
||||
background:var(--surface3);border:1px solid var(--border2);border-radius:12px;
|
||||
font-size:12px;color:var(--text);white-space:nowrap}
|
||||
.email-tag.invalid{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.4);color:#fca5a5}
|
||||
font-size:12px;color:var(--text);white-space:nowrap;max-width:260px}
|
||||
.email-tag.invalid{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.4);color:#fca5a5}
|
||||
.tag-remove{background:none;border:none;color:var(--muted);cursor:pointer;
|
||||
padding:0;font-size:14px;line-height:1;margin-left:2px}
|
||||
padding:0 1px;font-size:14px;line-height:1;flex-shrink:0}
|
||||
.tag-remove:hover{color:var(--text)}
|
||||
.tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px;
|
||||
font-family:inherit;min-width:120px;flex:1;padding:1px 0}
|
||||
font-family:inherit;min-width:80px;flex:1;padding:1px 0;pointer-events:all;cursor:text}
|
||||
|
||||
/* ── Compose resize handle ───────────────────────────────────── */
|
||||
#compose-resize-handle{position:absolute;top:0;left:0;right:0;height:5px;
|
||||
cursor:n-resize;border-radius:10px 10px 0 0;z-index:1}
|
||||
#compose-resize-handle:hover{background:var(--accent);opacity:.4}
|
||||
.compose-window{position:relative;display:flex;flex-direction:column;
|
||||
min-width:360px;min-height:280px;resize:none}
|
||||
.compose-attach-list{display:flex;flex-wrap:wrap;gap:6px;padding:6px 14px 0;min-height:0}
|
||||
/* ── Accounts popup ──────────────────────────────────────────── */
|
||||
.accounts-popup{
|
||||
position:fixed;bottom:52px;left:8px;
|
||||
width:300px;background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.55);
|
||||
z-index:300;display:none;flex-direction:column;overflow:hidden;
|
||||
}
|
||||
.accounts-popup.open{display:flex}
|
||||
.accounts-popup-backdrop{display:none;position:fixed;inset:0;z-index:299}
|
||||
.accounts-popup-backdrop.open{display:block}
|
||||
.accounts-popup-inner{padding:12px}
|
||||
.accounts-popup-header{display:flex;align-items:center;justify-content:space-between;
|
||||
font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.9px;
|
||||
color:var(--muted);margin-bottom:8px}
|
||||
.acct-popup-item{display:flex;align-items:center;gap:6px;padding:7px 6px;border-radius:7px;
|
||||
transition:background .1s}
|
||||
.acct-popup-item:hover{background:var(--surface3)}
|
||||
.accounts-add-btn{display:flex;align-items:center;gap:7px;width:100%;padding:8px 6px;
|
||||
margin-top:4px;background:none;border:1px dashed var(--border2);border-radius:7px;
|
||||
color:var(--accent);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;
|
||||
transition:background .1s}
|
||||
.accounts-add-btn:hover{background:var(--accent-dim)}
|
||||
|
||||
/* ── Icon sync button ─────────────────────────────────────────── */
|
||||
/* ── Inline confirm toast ────────────────────────────────────── */
|
||||
.inline-confirm{
|
||||
position:fixed;top:50%;left:50%;transform:translate(-50%,-44%);
|
||||
background:var(--surface2);border:1px solid var(--border2);border-radius:12px;
|
||||
box-shadow:0 24px 64px rgba(0,0,0,.7);padding:20px 22px;
|
||||
min-width:300px;max-width:440px;z-index:400;
|
||||
opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
|
||||
}
|
||||
.inline-confirm.open{opacity:1;pointer-events:all;transform:translate(-50%,-50%)}
|
||||
|
||||
/* ── Context menu submenu ────────────────────────────────────── */
|
||||
.ctx-has-sub{position:relative;justify-content:space-between}
|
||||
.ctx-sub-arrow{margin-left:auto;font-size:12px;color:var(--muted);pointer-events:none}
|
||||
.ctx-submenu{
|
||||
display:none;position:absolute;left:100%;top:-4px;
|
||||
background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:8px;padding:4px;min-width:160px;
|
||||
box-shadow:0 8px 28px rgba(0,0,0,.55);z-index:210;
|
||||
}
|
||||
.ctx-has-sub:hover>.ctx-submenu{display:block}
|
||||
.ctx-sub-item{white-space:nowrap}
|
||||
|
||||
/* ── Multi-select & drag-drop ────────────────────────────────── */
|
||||
.message-item.selected{background:rgba(74,144,226,.18)!important;outline:1px solid var(--accent)}
|
||||
.message-item.selected:hover{background:rgba(74,144,226,.26)!important}
|
||||
.nav-folder.drag-over,.nav-item.drag-over{background:rgba(74,144,226,.22)!important;border-radius:6px}
|
||||
#bulk-action-bar{display:none}
|
||||
.folder-nosync{opacity:.65}
|
||||
|
||||
/* ── Compose attach list ─────────────────────────────────────── */
|
||||
.compose-attach-list{display:flex;flex-wrap:wrap;gap:6px;padding:4px 14px 0;min-height:0;flex-shrink:0}
|
||||
|
||||
/* ── Icon sync button ────────────────────────────────────────── */
|
||||
.icon-sync-btn{background:none;border:none;color:var(--muted);cursor:pointer;
|
||||
padding:2px;border-radius:4px;line-height:1;flex-shrink:0;transition:color .15s}
|
||||
.icon-sync-btn:hover{color:var(--text)}
|
||||
|
||||
/* ── Message filter dropdown ─────────────────────────────────── */
|
||||
.filter-dropdown{position:relative}
|
||||
.filter-dropdown-btn{display:flex;align-items:center;gap:5px;background:none;
|
||||
border:1px solid var(--border2);border-radius:6px;color:var(--muted);
|
||||
font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;
|
||||
padding:4px 9px;transition:all .1s;white-space:nowrap}
|
||||
.filter-dropdown-btn:hover{background:var(--surface3);color:var(--text)}
|
||||
.filter-dropdown-btn.active{border-color:rgba(91,141,239,.4);color:var(--accent);background:var(--accent-dim)}
|
||||
.filter-dropdown-menu{position:absolute;top:calc(100% + 6px);right:0;
|
||||
background:var(--surface2);border:1px solid var(--border2);border-radius:8px;
|
||||
box-shadow:0 8px 28px rgba(0,0,0,.5);min-width:160px;padding:4px;
|
||||
z-index:250}
|
||||
.filter-opt{padding:7px 12px;border-radius:5px;font-size:13px;cursor:pointer;
|
||||
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}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,3 +108,24 @@ function insertLink() {
|
||||
document.getElementById('compose-editor').focus();
|
||||
document.execCommand('createLink', false, url);
|
||||
}
|
||||
|
||||
// ── Filter dropdown (stubs — real logic in app.js, but onclick needs global scope) ──
|
||||
function goMailToggleFilter(e) {
|
||||
e.stopPropagation();
|
||||
const menu = document.getElementById('filter-dropdown-menu');
|
||||
if (!menu) return;
|
||||
const isOpen = menu.classList.contains('open');
|
||||
menu.classList.toggle('open', !isOpen);
|
||||
if (!isOpen) {
|
||||
document.addEventListener('click', function closeFilter() {
|
||||
menu.classList.remove('open');
|
||||
document.removeEventListener('click', closeFilter);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function goMailSetFilter(mode) {
|
||||
var menu = document.getElementById('filter-dropdown-menu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
if (typeof setFilter === 'function') setFilter(mode);
|
||||
}
|
||||
|
||||
@@ -14,15 +14,6 @@
|
||||
<button class="compose-btn" onclick="openCompose()">+ Compose</button>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="section-label">Accounts</div>
|
||||
<div id="accounts-list"></div>
|
||||
<div class="add-account-btn" onclick="openModal('add-account-modal')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
Connect account
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-item active" id="nav-unified" onclick="selectFolder('unified','Unified Inbox')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><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>
|
||||
@@ -42,6 +33,9 @@
|
||||
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Admin</a>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button class="icon-btn" id="accounts-btn" onclick="toggleAccountsMenu(event)" title="Manage accounts">
|
||||
<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="openSettings()" title="Settings">
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
</button>
|
||||
@@ -56,7 +50,24 @@
|
||||
<div class="message-list-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" id="panel-title">Unified Inbox</span>
|
||||
<span class="panel-count" id="panel-count"></span>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="panel-count" id="panel-count"></span>
|
||||
<div class="filter-dropdown" id="filter-dropdown">
|
||||
<button class="filter-dropdown-btn" id="filter-dropdown-btn" title="Filter & sort" onclick="var m=document.getElementById('filter-dropdown-menu');m.style.display=m.style.display==='block'?'none':'block';event.stopPropagation()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"/></svg>
|
||||
<span id="filter-label">Filter</span>
|
||||
</button>
|
||||
<div class="filter-dropdown-menu" id="filter-dropdown-menu" style="display:none">
|
||||
<div class="filter-opt" id="fopt-default" onclick="goMailSetFilter('default');event.stopPropagation()">✓ Default order</div>
|
||||
<div class="filter-sep-line"></div>
|
||||
<div class="filter-opt" id="fopt-unread" onclick="goMailSetFilter('unread');event.stopPropagation()">○ Unread only</div>
|
||||
<div class="filter-sep-line"></div>
|
||||
<div class="filter-opt" id="fopt-date-desc" onclick="goMailSetFilter('date-desc');event.stopPropagation()">○ Newest first</div>
|
||||
<div class="filter-opt" id="fopt-date-asc" onclick="goMailSetFilter('date-asc');event.stopPropagation()">○ Oldest first</div>
|
||||
<div class="filter-opt" id="fopt-size-desc" onclick="goMailSetFilter('size-desc');event.stopPropagation()">○ Largest first</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
@@ -79,14 +90,34 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Compose window -->
|
||||
<div class="compose-overlay" id="compose-overlay">
|
||||
<div class="compose-window" id="compose-window">
|
||||
<div id="compose-resize-handle"></div>
|
||||
<div class="compose-header">
|
||||
<span class="compose-title" id="compose-title">New Message</span>
|
||||
<button class="compose-close" onclick="closeCompose()">×</button>
|
||||
<!-- ── Accounts submenu popup ──────────────────────────────────────────────── -->
|
||||
<div class="accounts-popup" id="accounts-popup">
|
||||
<div class="accounts-popup-inner">
|
||||
<div class="accounts-popup-header">
|
||||
<span>Accounts</span>
|
||||
<button class="icon-btn" onclick="closeAccountsMenu()" style="margin:-4px -4px -4px 0">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="accounts-popup-list"></div>
|
||||
<button class="accounts-add-btn" onclick="closeAccountsMenu();openAddAccountModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
Connect new account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounts-popup-backdrop" id="accounts-popup-backdrop" onclick="closeAccountsMenu()"></div>
|
||||
|
||||
<!-- ── Draggable Compose dialog ───────────────────────────────────────────── -->
|
||||
<div class="compose-dialog" id="compose-dialog">
|
||||
<div class="compose-dialog-header" id="compose-drag-handle">
|
||||
<span class="compose-title" id="compose-title">New Message</span>
|
||||
<div style="display:flex;align-items:center;gap:2px">
|
||||
<button class="compose-close" onclick="minimizeCompose()" title="Minimise">–</button>
|
||||
<button class="compose-close" onclick="closeCompose()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compose-body-wrap" id="compose-body-wrap">
|
||||
<div class="compose-field"><label>From</label><select id="compose-from"></select></div>
|
||||
<div class="compose-field compose-tag-field"><label>To</label><div id="compose-to" class="tag-container"></div></div>
|
||||
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label>CC</label><div id="compose-cc-tags" class="tag-container"></div></div>
|
||||
@@ -108,17 +139,39 @@
|
||||
<div class="compose-footer">
|
||||
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
|
||||
<div style="display:flex;gap:6px;margin-left:4px">
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="document.getElementById('cc-row').style.display='flex'">+CC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="document.getElementById('bcc-row').style.display='flex'">+BCC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="showCCRow()">+CC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="showBCCRow()">+BCC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">📎 Attach</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">✎ Draft</button>
|
||||
</div>
|
||||
<input type="file" id="compose-attach-input" multiple style="display:none" onchange="handleAttachFiles(this)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="compose-resize" data-dir="e"></div>
|
||||
<div class="compose-resize" data-dir="s"></div>
|
||||
<div class="compose-resize" data-dir="se"></div>
|
||||
<div class="compose-resize" data-dir="w"></div>
|
||||
<div class="compose-resize" data-dir="sw"></div>
|
||||
<div class="compose-resize" data-dir="n"></div>
|
||||
<div class="compose-resize" data-dir="ne"></div>
|
||||
<div class="compose-resize" data-dir="nw"></div>
|
||||
</div>
|
||||
<!-- Minimised pill (shown when user clicks – on header) -->
|
||||
<div class="compose-minimised" id="compose-minimised" onclick="restoreCompose()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><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>
|
||||
<span id="compose-minimised-label">New Message</span>
|
||||
</div>
|
||||
|
||||
<!-- Add Account Modal -->
|
||||
<!-- ── Inline confirm (replaces browser confirm()) ───────────────────────── -->
|
||||
<div class="inline-confirm" id="inline-confirm">
|
||||
<p id="inline-confirm-msg" style="margin:0 0 14px;font-size:13px;line-height:1.5"></p>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn-secondary" style="font-size:12px" id="inline-confirm-cancel">Cancel</button>
|
||||
<button class="btn-danger" style="font-size:12px" id="inline-confirm-ok">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add Account Modal ──────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="add-account-modal">
|
||||
<div class="modal">
|
||||
<h2>Connect an account</h2>
|
||||
@@ -134,9 +187,18 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-divider"><span>or add IMAP account</span></div>
|
||||
<div class="modal-field"><label>Email Address</label><input type="email" id="imap-email" placeholder="you@example.com"></div>
|
||||
<div class="modal-field"><label>Email Address</label>
|
||||
<div style="display:flex;gap:8px;flex:1">
|
||||
<input type="email" id="imap-email" placeholder="you@example.com" style="flex:1">
|
||||
<button class="btn-secondary" id="detect-btn" onclick="detectMailSettings()" style="white-space:nowrap;font-size:12px">Auto-detect</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field"><label>Display Name</label><input type="text" id="imap-name" placeholder="Your Name"></div>
|
||||
<div class="modal-field"><label>Password / App Password</label><input type="password" id="imap-password"></div>
|
||||
<div style="font-size:11px;color:var(--muted);padding:0 0 8px;line-height:1.6">
|
||||
Common ports — IMAP: <strong>993</strong> TLS/SSL, <strong>143</strong> STARTTLS/Plain ·
|
||||
SMTP: <strong>587</strong> STARTTLS, <strong>465</strong> TLS/SSL, <strong>25</strong> Plain
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
|
||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
|
||||
@@ -154,7 +216,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Account Modal -->
|
||||
<!-- ── Edit Account Modal ─────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="edit-account-modal">
|
||||
<div class="modal">
|
||||
<h2>Account Settings</h2>
|
||||
@@ -172,17 +234,25 @@
|
||||
</div>
|
||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||
<div class="modal-field">
|
||||
<label>Import mode</label>
|
||||
<label>Email history to sync</label>
|
||||
<select id="edit-sync-mode" onchange="toggleSyncDaysField()" style="padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none">
|
||||
<option value="days">Last N days</option>
|
||||
<option value="all">Full mailbox (all email)</option>
|
||||
<option value="preset-30">Last 1 month</option>
|
||||
<option value="preset-90">Last 3 months</option>
|
||||
<option value="preset-180">Last 6 months</option>
|
||||
<option value="preset-365">Last 1 year</option>
|
||||
<option value="preset-730">Last 2 years</option>
|
||||
<option value="preset-1825">Last 5 years</option>
|
||||
<option value="all" selected>All emails (full mailbox)</option>
|
||||
<option value="days">Custom (days)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-row" id="edit-sync-days-row">
|
||||
<div class="modal-field"><label>Days to fetch</label><input type="number" id="edit-sync-days" value="30" min="1" max="3650"></div>
|
||||
<div class="modal-row" id="edit-sync-days-row" style="display:none">
|
||||
<div class="modal-field"><label>Custom days to fetch</label><input type="number" id="edit-sync-days" value="30" min="1" max="36500"></div>
|
||||
</div>
|
||||
<div id="edit-conn-result" class="test-result" style="display:none"></div>
|
||||
<div id="edit-last-error" style="display:none" class="alert error"></div>
|
||||
<div class="settings-group-title" style="margin:16px 0 8px">Hidden Folders</div>
|
||||
<div id="edit-hidden-folders" style="font-size:12px;color:var(--muted)">Loading…</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('edit-account-modal')">Cancel</button>
|
||||
<button class="btn-secondary" id="edit-test-btn" onclick="testEditConnection()">Test Connection</button>
|
||||
@@ -191,7 +261,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<!-- ── Settings Modal ─────────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="settings-modal">
|
||||
<div class="modal" style="width:520px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
||||
@@ -216,15 +286,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Compose Window</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Open new message as an in-page panel (default) or a separate popup window.</div>
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:13px;cursor:pointer">
|
||||
<input type="checkbox" id="compose-popup-toggle" onchange="saveComposePopupPref()">
|
||||
Open compose in new popup window
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Change Password</div>
|
||||
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
|
||||
@@ -247,5 +308,5 @@
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/app.js?v=11"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}GoMail{{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">
|
||||
<link rel="stylesheet" href="/static/css/gomail.css?v=11">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
{{block "body" .}}{{end}}
|
||||
<script src="/static/js/gomail.js"></script>
|
||||
<script src="/static/js/gomail.js?v=11"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user