fix labels

This commit is contained in:
2026-08-29 11:59:47 +01:00
parent f506183b6d
commit d005cbb931
9 changed files with 608 additions and 6 deletions
+7
View File
@@ -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")
+223
View File
@@ -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,
+103
View File
@@ -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"))
+12
View File
@@ -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 ----
+34 -1
View File
@@ -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}
+167 -1
View File
@@ -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 => `
<div class="label-dropdown-row">
<span class="nav-label-dot" style="background:${l.color};cursor:pointer" onclick="selectLabel(${l.id},'${esc(l.name)}')"></span>
<span class="label-dropdown-name" onclick="selectLabel(${l.id},'${esc(l.name)}')">${esc(l.name)}</span>
<span class="label-dropdown-actions">
<button onclick="event.stopPropagation();closeLabelsDropdown();openLabelEditor(${l.id})" title="Rename / recolor"></button>
<button onclick="event.stopPropagation();closeLabelsDropdown();deleteLabelConfirm(${l.id})" title="Delete">🗑</button>
</span>
</div>`).join('');
el.innerHTML = rows + `
<div class="filter-sep-line"></div>
<div class="label-dropdown-new" onclick="closeLabelsDropdown();openLabelEditor()">+ New label</div>`;
}
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 =>
`<span class="label-swatch${c===chosen?' selected':''}" style="background:${c}" data-color="${c}" onclick="pickLabelColor('${c}')"></span>`
).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 `<div class="ctx-item label-picker-item" onclick="toggleMessageLabel(${msgId},${l.id});closeMenu()">
<span class="nav-label-dot" style="background:${l.color}"></span>${on?' ':' '}${esc(l.name)}
</div>`;
}).join('') || '<div class="ctx-item" style="color:var(--muted)">No labels yet</div>';
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='<div class="empty-state"><p>Failed to load</p></div>';return;}
S.totalMessages=result.total||(result.messages||[]).length;
@@ -1113,6 +1245,7 @@ function renderMessageList() {
<span class="msg-icons">
${m.size?`<span class="msg-size">${formatSize(m.size)}</span>`:''}
${m.has_attachment?'<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--muted)"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>':''}
${m.labels?.length?`<span class="msg-label-dots">${m.labels.map(l=>`<span class="msg-label-dot" style="background:${l.color}" title="${esc(l.name)}"></span>`).join('')}</span>`:''}
<span class="msg-star ${m.is_starred?'on':''}" onclick="toggleStar(${m.id},event)">${m.is_starred?'★':'☆'}</span>
</span>
</div>
@@ -1341,6 +1474,13 @@ function renderMessageDetail(msg, showRemoteContent) {
</div>
<div class="detail-date">${formatFullDate(msg.date)}</div>
</div>
<div class="detail-labels">
${(msg.labels||[]).map(l=>`<span class="label-chip" style="background:${l.color}22;color:${l.color};border-color:${l.color}55">
<span class="label-chip-dot" style="background:${l.color}"></span>${esc(l.name)}
<button onclick="toggleMessageLabel(${msg.id},${l.id})" title="Remove label">×</button>
</span>`).join('')}
<button class="label-add-btn" onclick="toggleLabelPicker(event,${msg.id})">+ Label</button>
</div>
</div>
<div class="detail-actions">
<button class="action-btn" onclick="openReply()"> Reply</button>
@@ -1445,6 +1585,17 @@ function showMessageMenu(e, id) {
<span class="ctx-sub-arrow"></span>
<div class="ctx-submenu">${moveItems}</div>
</div>` : '';
const labelItems = S.labels.map(l => {
const on = msg?.labels?.some(x=>x.id===l.id);
return `<div class="ctx-item ctx-sub-item label-picker-item" onclick="toggleMessageLabel(${id},${l.id});closeMenu()">
<span class="nav-label-dot" style="background:${l.color}"></span>${on?' ':' '}${esc(l.name)}
</div>`;
}).join('');
const labelSub = S.labels.length ? `
<div class="ctx-item ctx-has-sub">🏷 Label as
<span class="ctx-sub-arrow"></span>
<div class="ctx-submenu">${labelItems}</div>
</div>` : '';
showCtxMenu(e,`
<div class="ctx-item" onclick="window.open('/message/${id}','_blank');closeMenu()"> Open in new tab</div>
<div class="ctx-sep"></div>
@@ -1453,6 +1604,7 @@ function showMessageMenu(e, id) {
<div class="ctx-item" onclick="markRead(${id},${msg?.is_read?'false':'true'});closeMenu()">${msg?.is_read?'Mark unread':'Mark read'}</div>
<div class="ctx-sep"></div>
${moveSub}
${labelSub}
<div class="ctx-item" onclick="showMessageHeaders(${id});closeMenu()"> View headers</div>
<div class="ctx-item" onclick="downloadEML(${id});closeMenu()"> Download .eml</div>
<div class="ctx-sep"></div>
@@ -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'; }
});
});
}
+33
View File
@@ -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;
+27 -2
View File
@@ -110,6 +110,13 @@
<div class="filter-opt" id="vopt-sidebar-auto" onclick="setViewPref('sidebarMode','auto');event.stopPropagation()">○ Auto-hide (peek on hover)</div>
</div>
</div>
<div class="filter-dropdown" id="labels-dropdown">
<button class="filter-dropdown-btn" id="labels-dropdown-btn" title="Labels" onclick="toggleLabelsDropdown(event)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16z"/></svg>
<span>Labels</span>
</button>
<div class="filter-dropdown-menu" id="labels-dropdown-menu" style="display:none;min-width:210px"></div>
</div>
<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>
@@ -408,6 +415,24 @@
</div>
</div>
<!-- ── Label Editor Modal (create/rename/recolor) ────────────────────────────── -->
<div class="modal-overlay" id="label-editor-modal">
<div class="modal" style="max-width:360px">
<h2 id="label-editor-title">New Label</h2>
<input type="hidden" id="label-editor-id">
<div class="modal-field"><label>Name</label><input type="text" id="label-editor-name" maxlength="40"></div>
<div class="modal-field">
<label>Color</label>
<div class="label-swatches" id="label-editor-swatches"></div>
<input type="color" id="label-editor-custom-color" onchange="pickLabelColor(this.value)" style="width:40px;height:28px;padding:0;border:1px solid var(--border);border-radius:6px;background:none;cursor:pointer">
</div>
<div class="modal-actions">
<button class="modal-cancel" onclick="closeModal('label-editor-modal')">Cancel</button>
<button class="modal-submit" onclick="saveLabelEditor()">Save</button>
</div>
</div>
</div>
<!-- ── Edit Account Modal ─────────────────────────────────────────────────── -->
<div class="modal-overlay" id="edit-account-modal">
<div class="modal">
@@ -755,6 +780,6 @@
{{end}}
{{define "scripts"}}
<script src="/static/js/app.js?v=67"></script>
<script src="/static/js/contacts_calendar.js?v=67"></script>
<script src="/static/js/app.js?v=70"></script>
<script src="/static/js/contacts_calendar.js?v=70"></script>
{{end}}
+2 -2
View File
@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}GoWebMail{{end}}</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=67">
<link rel="stylesheet" href="/static/css/gowebmail.css?v=70">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/gowebmail.js?v=67"></script>
<script src="/static/js/gowebmail.js?v=70"></script>
{{block "scripts" .}}{{end}}
</body>
</html>