diff --git a/cmd/server/main.go b/cmd/server/main.go index 81b077a..7027c85 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -225,6 +225,13 @@ func main() { api.HandleFunc("/messages/{id:[0-9]+}/attachments/{att_id:[0-9]+}", h.API.DownloadAttachment).Methods("GET") api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE") api.HandleFunc("/messages/starred", h.API.StarredMessages).Methods("GET") + api.HandleFunc("/messages/by-label/{id:[0-9]+}", h.API.MessagesByLabel).Methods("GET") + api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.AssignLabel).Methods("POST") + api.HandleFunc("/messages/{id:[0-9]+}/labels/{label_id:[0-9]+}", h.API.UnassignLabel).Methods("DELETE") + api.HandleFunc("/labels", h.API.ListLabels).Methods("GET") + api.HandleFunc("/labels", h.API.CreateLabel).Methods("POST") + api.HandleFunc("/labels/{id:[0-9]+}", h.API.UpdateLabel).Methods("PUT") + api.HandleFunc("/labels/{id:[0-9]+}", h.API.DeleteLabel).Methods("DELETE") // Remote content whitelist api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET") diff --git a/internal/db/db.go b/internal/db/db.go index 07ff95f..4cc2d4e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -424,6 +424,24 @@ func (d *DB) Migrate() error { return fmt.Errorf("create pgp_contacts: %w", err) } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL, + created_at DATETIME DEFAULT (datetime('now')), + UNIQUE(user_id, name COLLATE NOCASE) + )`); err != nil { + return fmt.Errorf("create labels: %w", err) + } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS message_labels ( + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, label_id) + )`); err != nil { + return fmt.Errorf("create message_labels: %w", err) + } + if _, err := d.sql.Exec(`CREATE TABLE IF NOT EXISTS trusted_certs ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, @@ -448,6 +466,7 @@ func (d *DB) Migrate() error { d.backfillSearchIndex() d.runOnce("sync_all_folders_by_default", d.backfillSyncAllFoldersDefault) + d.runOnce("seed_default_labels_existing_users", d.seedDefaultLabelsForExistingUsers) // Bootstrap admin account if no users exist return d.bootstrapAdmin() @@ -533,10 +552,46 @@ func (d *DB) bootstrapAdmin() error { if err != nil { return fmt.Errorf("bootstrap admin: %w", err) } + var adminID int64 + d.sql.QueryRow(`SELECT id FROM users WHERE username='admin'`).Scan(&adminID) + d.seedDefaultLabels(adminID) fmt.Println("WARNING: Default admin account created: username=admin password=admin — CHANGE THIS IMMEDIATELY") return nil } +// seedDefaultLabels gives a newly created user the starter label set, each a preset color. +func (d *DB) seedDefaultLabels(userID int64) { + defaults := []struct{ name, color string }{ + {"Important", "#e5484d"}, + {"Personal", "#5b8def"}, + {"Work", "#f5a623"}, + {"ToDo", "#7c5cfc"}, + } + for _, l := range defaults { + d.sql.Exec(`INSERT OR IGNORE INTO labels (user_id, name, color) VALUES (?,?,?)`, userID, l.name, l.color) + } +} + +// seedDefaultLabelsForExistingUsers backfills the starter label set for users created +// before the labels feature existed (bootstrapAdmin/CreateUser only seed new users). +func (d *DB) seedDefaultLabelsForExistingUsers() { + rows, err := d.sql.Query(`SELECT id FROM users`) + if err != nil { + return + } + var ids []int64 + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + rows.Close() + for _, id := range ids { + d.seedDefaultLabels(id) + } +} + // ---- Users ---- func (d *DB) CreateUser(username, email, password string, role models.UserRole) (*models.User, error) { @@ -555,6 +610,7 @@ func (d *DB) CreateUser(username, email, password string, role models.UserRole) return nil, err } id, _ := res.LastInsertId() + d.seedDefaultLabels(id) return d.GetUserByID(id) } @@ -1296,6 +1352,166 @@ func (d *DB) ListFoldersByAccount(accountID int64) ([]*models.Folder, error) { return folders, rows.Err() } +// ---- Labels ---- + +func (d *DB) ListLabels(userID int64) ([]models.Label, error) { + rows, err := d.sql.Query(`SELECT id, user_id, name, color FROM labels WHERE user_id=? ORDER BY name COLLATE NOCASE`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var labels []models.Label + for rows.Next() { + var l models.Label + if err := rows.Scan(&l.ID, &l.UserID, &l.Name, &l.Color); err != nil { + return nil, err + } + labels = append(labels, l) + } + return labels, rows.Err() +} + +func (d *DB) CreateLabel(userID int64, name, color string) (*models.Label, error) { + res, err := d.sql.Exec(`INSERT INTO labels (user_id, name, color) VALUES (?,?,?)`, userID, name, color) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return nil, fmt.Errorf("a label named %q already exists", name) + } + return nil, err + } + id, _ := res.LastInsertId() + return &models.Label{ID: id, UserID: userID, Name: name, Color: color}, nil +} + +func (d *DB) UpdateLabel(labelID, userID int64, name, color string) error { + _, err := d.sql.Exec(`UPDATE labels SET name=?, color=? WHERE id=? AND user_id=?`, name, color, labelID, userID) + if err != nil && strings.Contains(err.Error(), "UNIQUE") { + return fmt.Errorf("a label named %q already exists", name) + } + return err +} + +func (d *DB) DeleteLabel(labelID, userID int64) error { + _, err := d.sql.Exec(`DELETE FROM labels WHERE id=? AND user_id=?`, labelID, userID) + return err +} + +// AssignLabel attaches a label to a message. Both are scoped to userID so a user can't +// label another user's message or use another user's label. +func (d *DB) AssignLabel(messageID, labelID, userID int64) error { + _, err := d.sql.Exec(` + INSERT OR IGNORE INTO message_labels (message_id, label_id) + SELECT m.id, l.id FROM messages m, labels l + WHERE m.id=? AND l.id=? + AND m.account_id IN (SELECT id FROM email_accounts WHERE user_id=?) + AND l.user_id=?`, + messageID, labelID, userID, userID, + ) + return err +} + +func (d *DB) UnassignLabel(messageID, labelID, userID int64) error { + _, err := d.sql.Exec(` + DELETE FROM message_labels WHERE message_id=? AND label_id=? + AND message_id IN (SELECT m.id FROM messages m WHERE m.account_id IN (SELECT id FROM email_accounts WHERE user_id=?))`, + messageID, labelID, userID, + ) + return err +} + +// attachLabels batch-loads labels for a page of message summaries (one query instead of +// one per row) and fills in each summary's Labels field in place. +func (d *DB) attachLabels(msgs []models.MessageSummary) { + if len(msgs) == 0 { + return + } + idIdx := make(map[int64]int, len(msgs)) + args := make([]interface{}, len(msgs)) + for i, m := range msgs { + idIdx[m.ID] = i + args[i] = m.ID + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(args)), ",") + rows, err := d.sql.Query(` + SELECT ml.message_id, l.id, l.user_id, l.name, l.color + FROM message_labels ml JOIN labels l ON l.id = ml.label_id + WHERE ml.message_id IN (`+placeholders+`)`, args...) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var mid int64 + var l models.Label + if rows.Scan(&mid, &l.ID, &l.UserID, &l.Name, &l.Color) == nil { + if i, ok := idIdx[mid]; ok { + msgs[i].Labels = append(msgs[i].Labels, l) + } + } + } +} + +// ListMessagesByLabel returns all messages (across every account) tagged with labelID, +// newest first — a virtual folder, same pattern as ListStarredMessages. +func (d *DB) ListMessagesByLabel(labelID, userID int64, page, pageSize int) (*models.PagedMessages, error) { + offset := (page - 1) * pageSize + var total int + d.sql.QueryRow(` + SELECT COUNT(*) FROM message_labels ml + JOIN messages m ON m.id = ml.message_id + JOIN email_accounts a ON a.id = m.account_id + WHERE ml.label_id=? AND a.user_id=?`, labelID, 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 message_labels ml + JOIN messages m ON m.id = ml.message_id + JOIN email_accounts a ON a.id = m.account_id + JOIN folders f ON f.id = m.folder_id + WHERE ml.label_id=? AND a.user_id=? + ORDER BY m.date DESC + LIMIT ? OFFSET ?`, labelID, 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 + } + d.attachLabels(summaries) + + return &models.PagedMessages{ + Messages: summaries, + Total: total, + Page: page, + PageSize: pageSize, + HasMore: offset+len(summaries) < total, + }, nil +} + // ---- Messages ---- func (d *DB) UpsertMessage(m *models.Message) error { @@ -1401,6 +1617,10 @@ func (d *DB) GetMessage(messageID, userID int64) (*models.Message, error) { m.Attachments = atts } + summary := []models.MessageSummary{{ID: m.ID}} + d.attachLabels(summary) + m.Labels = summary[0].Labels + return m, nil } @@ -1469,6 +1689,7 @@ func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page if err := rows.Err(); err != nil { return nil, err } + d.attachLabels(summaries) return &models.PagedMessages{ Messages: summaries, @@ -1597,6 +1818,7 @@ func (d *DB) SearchMessages(userID int64, q string, filters SearchFilters, page, s.Preview = bodyText summaries = append(summaries, s) } + d.attachLabels(summaries) return &models.PagedMessages{ Messages: summaries, Total: total, Page: page, PageSize: pageSize, @@ -1894,6 +2116,7 @@ func (d *DB) ListStarredMessages(userID int64, page, pageSize int) (*models.Page if err := rows.Err(); err != nil { return nil, err } + d.attachLabels(summaries) return &models.PagedMessages{ Messages: summaries, Total: total, diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 25277ed..66632e7 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1214,6 +1214,109 @@ func (h *APIHandler) GetMessageHeaders(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, map[string]interface{}{"headers": headers, "raw": rawHeaders}) } +// ---- Labels ---- + +func (h *APIHandler) ListLabels(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + labels, err := h.db.ListLabels(userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list labels") + return + } + h.writeJSON(w, labels) +} + +func (h *APIHandler) CreateLabel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct{ Name, Color string } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" || req.Color == "" { + h.writeError(w, http.StatusBadRequest, "name and color required") + return + } + label, err := h.db.CreateLabel(userID, req.Name, req.Color) + if err != nil { + h.writeError(w, http.StatusBadRequest, err.Error()) + return + } + h.writeJSON(w, label) +} + +func (h *APIHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + labelID := pathInt64(r, "id") + var req struct{ Name, Color string } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" || req.Color == "" { + h.writeError(w, http.StatusBadRequest, "name and color required") + return + } + if err := h.db.UpdateLabel(labelID, userID, req.Name, req.Color); err != nil { + h.writeError(w, http.StatusBadRequest, err.Error()) + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) DeleteLabel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + labelID := pathInt64(r, "id") + if err := h.db.DeleteLabel(labelID, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "delete failed") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) AssignLabel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + labelID := pathInt64(r, "label_id") + if err := h.db.AssignLabel(messageID, labelID, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to assign label") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) UnassignLabel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + labelID := pathInt64(r, "label_id") + if err := h.db.UnassignLabel(messageID, labelID, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to remove label") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) MessagesByLabel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + labelID := pathInt64(r, "id") + 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.ListMessagesByLabel(labelID, userID, page, pageSize) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list labeled messages") + return + } + h.writeJSON(w, result) +} + func (h *APIHandler) StarredMessages(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r) page, _ := strconv.Atoi(r.URL.Query().Get("page")) diff --git a/internal/models/models.go b/internal/models/models.go index c2b47ae..20eda16 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -126,6 +126,16 @@ type EmailAccount struct { LastSync time.Time `json:"last_sync"` CreatedAt time.Time `json:"created_at"` } +// Label is a user-defined organizational tag, local to gowebmail (not synced to the mail +// provider — labels don't have a reliable cross-provider equivalent: Gmail's are IMAP- +// extension-specific, Outlook's Categories need the Graph API, plain IMAP has none). +type Label struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Name string `json:"name"` + Color string `json:"color"` // hex, e.g. "#5b8def" +} + // Folder represents a mailbox folder or Gmail label. type Folder struct { ID int64 `json:"id"` @@ -188,6 +198,7 @@ type Message struct { IsDraft bool `json:"is_draft"` HasAttachment bool `json:"has_attachment"` Attachments []Attachment `json:"attachments,omitempty"` + Labels []Label `json:"labels,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -208,6 +219,7 @@ type MessageSummary struct { IsStarred bool `json:"is_starred"` HasAttachment bool `json:"has_attachment"` Size int64 `json:"size,omitempty"` // approximate; only populated by search results + Labels []Label `json:"labels,omitempty"` } // ---- Compose ---- diff --git a/web/static/css/gowebmail.css b/web/static/css/gowebmail.css index 2e81e57..7855df9 100644 --- a/web/static/css/gowebmail.css +++ b/web/static/css/gowebmail.css @@ -276,9 +276,42 @@ body.app-page{overflow:hidden} .msg-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0} .msg-icons{display:flex;align-items:center;gap:4px;flex-shrink:0} .msg-size{font-size:10px;color:var(--muted)} -.msg-star{color:var(--muted);font-size:11px;cursor:pointer} +.msg-star{color:var(--muted);font-size:15px;cursor:pointer} .msg-star.on{color:var(--star)} +/* ── Labels ──────────────────────────────────────────────────────────────── */ +.msg-label-dots{display:flex;align-items:center;gap:3px;flex-shrink:0} +.msg-label-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0} +.nav-label-dot{width:9px;height:9px;border-radius:50%;flex-shrink:0} +/* Labels dropdown (panel-header, next to Filter) */ +.label-dropdown-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:5px} +.label-dropdown-row:hover{background:var(--surface3)} +.label-dropdown-name{flex:1;cursor:pointer;font-size:13px;color:var(--text2)} +.label-dropdown-row:hover .label-dropdown-name{color:var(--text)} +.label-dropdown-actions{display:flex;gap:2px;opacity:0;transition:opacity .1s;flex-shrink:0} +.label-dropdown-row:hover .label-dropdown-actions{opacity:1} +.label-dropdown-actions button{background:none;border:none;color:var(--muted);cursor:pointer; + font-size:11px;padding:3px 5px;border-radius:3px} +.label-dropdown-actions button:hover{background:var(--surface2);color:var(--text)} +.label-dropdown-new{padding:7px 12px;border-radius:5px;font-size:13px;cursor:pointer;color:var(--accent)} +.label-dropdown-new:hover{background:var(--surface3)} +.detail-labels{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:8px} +.label-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 4px 2px 8px;border-radius:12px; + font-size:11px;font-weight:500;border:1px solid transparent} +.label-chip-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0} +.label-chip button{background:none;border:none;color:inherit;opacity:.6;cursor:pointer;font-size:13px; + line-height:1;padding:0 3px} +.label-chip button:hover{opacity:1} +.label-add-btn{font-size:11px;color:var(--muted);background:none;border:1px dashed var(--border2); + border-radius:12px;padding:2px 10px;cursor:pointer;transition:border-color .15s,color .15s} +.label-add-btn:hover{border-color:var(--accent);color:var(--accent)} +.label-picker-item{display:flex;align-items:center;gap:8px} +.label-swatches{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0} +.label-swatch{width:24px;height:24px;border-radius:50%;cursor:pointer;border:2px solid transparent; + transition:transform .1s,border-color .1s} +.label-swatch:hover{transform:scale(1.1)} +.label-swatch.selected{border-color:var(--text)} + /* Comfortable density (opt-in via #app-root[data-density="comfortable"]): restores the roomier 4-line row with account email and larger padding. */ #app-root[data-density="comfortable"] .message-item{padding:10px 12px} diff --git a/web/static/js/app.js b/web/static/js/app.js index 2fa21a5..8329715 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -3,7 +3,7 @@ // ── State ────────────────────────────────────────────────────────────────── const S = { me: null, accounts: [], providers: {gmail:false,outlook:false}, signatures: [], - folders: [], messages: [], totalMessages: 0, + folders: [], messages: [], totalMessages: 0, labels: [], currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox', currentMessage: null, selectedMessageId: null, searchQuery: '', composeMode: 'new', composeReplyToId: null, composeForwardFromId: null, @@ -139,6 +139,7 @@ async function init() { await loadAccounts(); await loadFolders(); + await loadLabels(); await loadMessages(); // Seed poller ID so we don't notify on initial load if (S.messages.length > 0) { @@ -907,6 +908,136 @@ function updateUnreadBadge() { badge.textContent=total; badge.style.display=total>0?'':'none'; } +// ── Labels ───────────────────────────────────────────────────────────────── +// Local to gowebmail only — not synced to Gmail/Outlook/IMAP (see server-side comment on +// models.Label for why a reliable cross-provider equivalent doesn't exist). +const LABEL_PALETTE = ['#e5484d','#f5a623','#f7ce46','#3dd68c','#12b886','#2bb3d6', + '#5b8def','#7c5cfc','#c25ee0','#ec4899','#8a8f98','#64748b']; + +async function loadLabels() { + const data = await api('GET','/labels'); + S.labels = data || []; + renderLabelsDropdown(); +} + +// Renders the Labels dropdown (panel-header, next to Filter): click a label to view its +// messages, or use the inline ✎/🗑 to rename/recolor or delete right from the list. +function renderLabelsDropdown() { + const el = document.getElementById('labels-dropdown-menu'); + if (!el) return; + const rows = S.labels.map(l => ` +
Failed to load