Compare commits

...

10 Commits

Author SHA1 Message Date
ghostersk
5d51b9778b message deletion sync fixed 2026-03-07 20:55:40 +00:00
ghostersk
b1fe22863a update MFA, add parameters to reset admin pw,mfa if locked out 2026-03-07 20:36:53 +00:00
ghostersk
12b1a44b96 Generally Working app 2026-03-07 20:29:20 +00:00
ghostersk
d4a4a5ec30 updated user management, add mesage select options 2026-03-07 20:00:15 +00:00
ghostersk
d5027ba7b0 Revise README for project status and instructions
Updated README to clarify project status and OAuth2 setup.
2026-03-07 17:24:27 +00:00
ghostersk
faa7dba2df Add feature images to README
Added images to showcase features in the README.
2026-03-07 17:18:42 +00:00
ghostersk
0bcd974b3d fixed message rendering with html content ( white background) 2026-03-07 17:09:41 +00:00
ghostersk
6df2de5f22 filter added. 2026-03-07 16:49:23 +00:00
ghostersk
b118056176 fixed sending of email(sending email considered it as spam) 2026-03-07 15:20:49 +00:00
ghostersk
1cf003edc4 fix the layout and email input for sending message 2026-03-07 15:14:57 +00:00
15 changed files with 2638 additions and 531 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
data/envs
data/*.db
data/*.db-shm
data/*db-wal
data/gomail.conf
data/*.txt

View File

@@ -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).

View File

@@ -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).
`)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 ----

View File

@@ -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()

View File

@@ -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)
})
}

View File

@@ -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 ----

View File

@@ -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
}

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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 &amp; 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()">&#215;</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">&#8211;</button>
<button class="compose-close" onclick="closeCompose()" title="Close">&#215;</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()">&#128206; Attach</button>
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">&#9998; 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 &nbsp;·&nbsp;
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}}

View File

@@ -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}}