From d005cbb9312e3f0d938f7801156fc9cd5989b3bf Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Sat, 29 Aug 2026 11:59:47 +0100 Subject: [PATCH] fix labels --- cmd/server/main.go | 7 ++ internal/db/db.go | 223 +++++++++++++++++++++++++++++++++++ internal/handlers/api.go | 103 ++++++++++++++++ internal/models/models.go | 12 ++ web/static/css/gowebmail.css | 35 +++++- web/static/js/app.js | 168 +++++++++++++++++++++++++- web/static/js/gowebmail.js | 33 ++++++ web/templates/app.html | 29 ++++- web/templates/base.html | 4 +- 9 files changed, 608 insertions(+), 6 deletions(-) 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 => ` +
+ + ${esc(l.name)} + + + + +
`).join(''); + el.innerHTML = rows + ` +
+
+ New label
`; +} + +function toggleLabelsDropdown(e) { + e.stopPropagation(); + const menu = document.getElementById('labels-dropdown-menu'); + if (!menu) return; + const isOpen = menu.style.display !== 'none'; + menu.style.display = isOpen ? 'none' : 'block'; + if (!isOpen) setTimeout(() => document.addEventListener('click', closeLabelsDropdown, { once: true }), 0); +} +function closeLabelsDropdown() { + const menu = document.getElementById('labels-dropdown-menu'); + if (menu) menu.style.display = 'none'; +} + +function selectLabel(labelId, name) { + closeLabelsDropdown(); + selectFolder('label:'+labelId, name); +} + +function openLabelEditor(labelId) { + const label = labelId ? S.labels.find(l=>l.id===labelId) : null; + document.getElementById('label-editor-title').textContent = label ? 'Edit Label' : 'New Label'; + document.getElementById('label-editor-id').value = labelId || ''; + document.getElementById('label-editor-name').value = label ? label.name : ''; + const chosen = label ? label.color : LABEL_PALETTE[0]; + document.getElementById('label-editor-swatches').innerHTML = LABEL_PALETTE.map(c => + `` + ).join(''); + document.getElementById('label-editor-custom-color').value = chosen; + openModal('label-editor-modal'); + setTimeout(()=>document.getElementById('label-editor-name').focus(), 50); +} + +function pickLabelColor(color) { + document.getElementById('label-editor-custom-color').value = color; + document.querySelectorAll('#label-editor-swatches .label-swatch').forEach(s => + s.classList.toggle('selected', s.dataset.color.toLowerCase() === color.toLowerCase())); +} + +async function saveLabelEditor() { + const id = document.getElementById('label-editor-id').value; + const name = document.getElementById('label-editor-name').value.trim(); + const color = document.getElementById('label-editor-custom-color').value; + if (!name) { toast('Label name required','error'); return; } + const r = id + ? await api('PUT','/labels/'+id,{name,color}) + : await api('POST','/labels',{name,color}); + if (r?.ok || r?.id) { + closeModal('label-editor-modal'); + toast(id?'Label updated':'Label created','success'); + await loadLabels(); + await loadMessages(); // refresh label chips/dots on visible messages + } else toast(r?.error || 'Failed to save label','error'); +} + +function deleteLabelConfirm(labelId) { + const label = S.labels.find(l=>l.id===labelId); + inlineConfirm(`Delete label "${label?.name||''}"? It will be removed from all messages.`, async () => { + const r = await api('DELETE','/labels/'+labelId); + if (r?.ok) { + toast('Label deleted','success'); + if (S.currentFolder === 'label:'+labelId) selectFolder('unified','Unified Inbox'); + await loadLabels(); + await loadMessages(); + } else toast('Delete failed','error'); + }); +} + +// Assigns/unassigns a label on a message and updates local state (list row + open detail) +// without a full reload. +async function toggleMessageLabel(msgId, labelId) { + const msg = S.messages.find(m=>m.id===msgId) || (S.currentMessage?.id===msgId ? S.currentMessage : null); + const has = msg?.labels?.some(l=>l.id===labelId); + const r = has + ? await api('DELETE','/messages/'+msgId+'/labels/'+labelId) + : await api('POST','/messages/'+msgId+'/labels/'+labelId); + if (!r?.ok) { toast('Failed to update label','error'); return; } + const label = S.labels.find(l=>l.id===labelId); + [S.messages.find(m=>m.id===msgId), S.currentMessage?.id===msgId?S.currentMessage:null].forEach(m => { + if (!m) return; + m.labels = m.labels || []; + if (has) m.labels = m.labels.filter(l=>l.id!==labelId); + else if (label) m.labels.push(label); + }); + renderMessageList(); + if (S.currentMessage?.id===msgId) renderMessageDetail(S.currentMessage,false); +} + +function toggleLabelPicker(e, msgId) { + e.stopPropagation(); + const msg = S.currentMessage?.id===msgId ? S.currentMessage : S.messages.find(m=>m.id===msgId); + const items = S.labels.map(l => { + const on = msg?.labels?.some(x=>x.id===l.id); + return `
+ ${on?'✓ ':'○ '}${esc(l.name)} +
`; + }).join('') || '
No labels yet
'; + showCtxMenu(e, items); +} + // ── Messages ─────────────────────────────────────────────────────────────── function selectFolder(folderId, folderName) { S.currentFolder=folderId; S.currentFolderName=folderName||S.currentFolderName; @@ -1029,6 +1160,7 @@ async function loadMessages(append) { } else if (S.currentFolder==='unified') result=await api('GET',`/messages/unified?page=${S.currentPage}&page_size=50`); else if (S.currentFolder==='starred') result=await api('GET',`/messages/starred?page=${S.currentPage}&page_size=50`); + else if (String(S.currentFolder).startsWith('label:')) result=await api('GET',`/messages/by-label/${String(S.currentFolder).slice(6)}?page=${S.currentPage}&page_size=50`); else result=await api('GET',`/messages?folder_id=${S.currentFolder}&page=${S.currentPage}&page_size=50`); if (!result){list.innerHTML='

Failed to load

';return;} S.totalMessages=result.total||(result.messages||[]).length; @@ -1113,6 +1245,7 @@ function renderMessageList() { ${m.size?`${formatSize(m.size)}`:''} ${m.has_attachment?'':''} + ${m.labels?.length?`${m.labels.map(l=>``).join('')}`:''} ${m.is_starred?'★':'☆'} @@ -1341,6 +1474,13 @@ function renderMessageDetail(msg, showRemoteContent) {
${formatFullDate(msg.date)}
+
+ ${(msg.labels||[]).map(l=>` + ${esc(l.name)} + + `).join('')} + +
@@ -1445,6 +1585,17 @@ function showMessageMenu(e, id) {
${moveItems}
` : ''; + const labelItems = S.labels.map(l => { + const on = msg?.labels?.some(x=>x.id===l.id); + return `
+ ${on?'✓ ':'○ '}${esc(l.name)} +
`; + }).join(''); + const labelSub = S.labels.length ? ` +
🏷 Label as + +
${labelItems}
+
` : ''; showCtxMenu(e,`
↗ Open in new tab
@@ -1453,6 +1604,7 @@ function showMessageMenu(e, id) {
${msg?.is_read?'Mark unread':'Mark read'}
${moveSub} + ${labelSub}
⋮ View headers
⬇ Download .eml
@@ -2387,6 +2539,20 @@ function showCtxMenu(e, html) { requestAnimationFrame(()=>{ menu.style.left=Math.min(e.clientX,window.innerWidth-menu.offsetWidth-8)+'px'; menu.style.top=Math.min(e.clientY,window.innerHeight-menu.offsetHeight-8)+'px'; + + // Submenus ("Move to", "Label as") default to opening right/below their parent item. + // Near a screen edge that pushes them off-view, so measure each one against the + // viewport and flip it to the opposite side when it wouldn't fit. + menu.querySelectorAll('.ctx-has-sub').forEach(parent=>{ + const sub=parent.querySelector('.ctx-submenu'); + if (!sub) return; + sub.style.left=''; sub.style.right=''; sub.style.top=''; sub.style.bottom=''; + sub.style.visibility='hidden'; sub.style.display='block'; + const pr=parent.getBoundingClientRect(), sw=sub.offsetWidth, sh=sub.offsetHeight; + sub.style.display=''; sub.style.visibility=''; + if (pr.right+sw>window.innerWidth) { sub.style.left='auto'; sub.style.right='100%'; } + if (pr.top+sh>window.innerHeight) { sub.style.top='auto'; sub.style.bottom='-4px'; } + }); }); } diff --git a/web/static/js/gowebmail.js b/web/static/js/gowebmail.js index 6f023af..187dfde 100644 --- a/web/static/js/gowebmail.js +++ b/web/static/js/gowebmail.js @@ -69,6 +69,39 @@ function positionMenu(menu, x, y) { menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 8) + 'px'; } +// ---- Long-press → right-click (touch devices have no right-click) ---- +// Every context menu in the app is wired via oncontextmenu="...". Touch devices never fire +// that event, so a ~550ms press-and-hold synthesizes a real 'contextmenu' event at the +// touch point instead — every existing handler picks it up unchanged. +(function () { + let timer = null, fired = false, start = null; + function cancel() { clearTimeout(timer); timer = null; } + document.addEventListener('touchstart', e => { + if (e.touches.length !== 1) { cancel(); return; } + const t = e.touches[0]; + start = { x: t.clientX, y: t.clientY, target: e.target }; + fired = false; + cancel(); + timer = setTimeout(() => { + fired = true; + if (navigator.vibrate) navigator.vibrate(15); + start.target.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, clientX: start.x, clientY: start.y, view: window, + })); + }, 550); + }, { passive: true }); + document.addEventListener('touchmove', e => { + if (!start || !timer) return; + const t = e.touches[0]; + if (Math.abs(t.clientX - start.x) > 10 || Math.abs(t.clientY - start.y) > 10) cancel(); + }, { passive: true }); + document.addEventListener('touchend', e => { + cancel(); + if (fired) { e.preventDefault(); fired = false; } // swallow the tap-through click + }, { passive: false }); + document.addEventListener('touchcancel', cancel, { passive: true }); +})(); + // ---- Debounce ---- function debounce(fn, ms) { let t; diff --git a/web/templates/app.html b/web/templates/app.html index 37d64d0..7cd79a3 100644 --- a/web/templates/app.html +++ b/web/templates/app.html @@ -110,6 +110,13 @@
○ Auto-hide (peek on hover)
+
+ + +
+ + +