// ── Email Plugin Frontend ── // // Self-registering plugin: // - Injects header button "mail" // - Registers a "mail" tab in core settings (accounts management lives there) // - Mail overlay shows welcome view when no accounts exist // - Otherwise shows split-pane reader // - Extracts messages as cards via /api/cards const hyprcrd = window.hyprcrd; if (!hyprcrd) throw new Error('[email plugin] window.hyprcrd missing'); const $ = (id) => document.getElementById(id); // ── State ───────────────────────────────────────────────────────── let accounts = []; let currentAccountId = null; let currentMessages = []; let currentMessage = null; let editingAccountId = null; let selectedUids = new Set(); // Pagination state let currentOffset = 0; let currentTotal = 0; const PAGE_SIZE_KEY = 'email_messages_per_page'; const PAGE_SIZE_DEFAULT = 30; const getPageSize = () => { const v = parseInt(localStorage.getItem(PAGE_SIZE_KEY) || ''); return Number.isFinite(v) && v >= 5 && v <= 200 ? v : PAGE_SIZE_DEFAULT; }; const setPageSize = (n) => { const clamped = Math.max(5, Math.min(200, parseInt(n) || PAGE_SIZE_DEFAULT)); localStorage.setItem(PAGE_SIZE_KEY, String(clamped)); return clamped; }; // Drag-select state for the message list let dragActive = false; let dragStartUid = null; let dragMoved = false; // true once mouse entered another row let dragMode = 'add'; // 'add' or 'remove' — based on start row's initial state // ── Overlay open / close ────────────────────────────────────────── async function openEmail() { $('email-overlay').classList.add('open'); selectedUids.clear(); currentOffset = 0; await loadAccounts(); if (accounts.length === 0) { showWelcomeView(); } else { // If currentAccountId still valid, keep it; otherwise reset if (!accounts.find(a => a.id === currentAccountId)) { currentAccountId = accounts[0].id; } $('email-account-select').value = currentAccountId; showMailView(); await loadMessages(); } } function closeEmail() { $('email-overlay').classList.remove('open'); } function showWelcomeView() { // Welcome is a plain block; the inner wrapper does flex centering with h-full $('email-view-welcome').classList.remove('hidden'); $('email-view-mail').classList.add('hidden'); $('email-view-mail').classList.remove('flex'); $('email-status').textContent = ''; } function showMailView() { $('email-view-welcome').classList.add('hidden'); $('email-view-welcome').classList.remove('flex'); $('email-view-mail').classList.remove('hidden'); $('email-view-mail').classList.add('flex'); } function gotoAccountsSettings() { closeEmail(); hyprcrd.settings.openTab('email'); } // ── Accounts: data ──────────────────────────────────────────────── async function loadAccounts() { const res = await fetch('api/email/accounts'); accounts = await res.json(); if (!Array.isArray(accounts)) accounts = []; renderAccountSelect(); } function renderAccountSelect() { const sel = $('email-account-select'); if (!sel) return; sel.innerHTML = accounts.length ? accounts.map(a => ``).join('') : ''; } // ── Settings tab: rendering ─────────────────────────────────────── function buildSettingsPanel(panelEl) { panelEl.innerHTML = `
preferences

Number of mails fetched per page (5–200). Affects all accounts.

mail accounts

Configure IMAP accounts to read mail inside hyprcrd. Passwords are encrypted with the vault key. For Gmail/Outlook, use an app password instead of your regular password.

add account
`; panelEl.querySelector('#email-account-form').addEventListener('submit', saveAccount); panelEl.querySelector('#email-acc-cancel').addEventListener('click', resetAccountForm); panelEl.querySelector('#email-acc-test').addEventListener('click', testCurrentForm); const pageSizeInput = panelEl.querySelector('#email-page-size'); const pageSizeStatus = panelEl.querySelector('#email-page-size-status'); const savePageSize = () => { const newSize = setPageSize(pageSizeInput.value); pageSizeInput.value = newSize; pageSizeStatus.textContent = 'saved'; pageSizeStatus.style.color = 'var(--t-accent)'; setTimeout(() => { pageSizeStatus.textContent = ''; }, 1500); // Reset to first page so the new size takes effect cleanly currentOffset = 0; }; pageSizeInput.addEventListener('change', savePageSize); pageSizeInput.addEventListener('blur', savePageSize); } async function refreshSettingsPanel(panelEl) { if (!panelEl.querySelector('#email-account-form')) { buildSettingsPanel(panelEl); } await loadAccounts(); renderAccountsList(); resetAccountForm(); } function renderAccountsList() { const list = $('email-accounts-list'); if (!list) return; if (!accounts.length) { list.innerHTML = '

No accounts configured yet. Use the form below to add one.

'; return; } list.innerHTML = accounts.map(a => `
`).join(''); list.querySelectorAll('.email-account-row').forEach(row => { const id = parseInt(row.dataset.id); row.querySelector('.email-edit-btn').addEventListener('click', () => editAccount(id)); row.querySelector('.email-del-btn').addEventListener('click', () => deleteAccount(id)); row.querySelector('.email-test-btn').addEventListener('click', () => testExistingAccount(id)); }); } function setAccountStatus(text, kind = 'info') { const el = $('email-acc-status'); if (!el) return; if (!text) { el.classList.add('hidden'); el.textContent = ''; el.style.borderColor = ''; el.style.color = ''; return; } el.classList.remove('hidden'); el.textContent = text; if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; } else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; } else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; } } function resetAccountForm() { editingAccountId = null; const form = $('email-account-form'); if (!form) return; $('email-form-title').textContent = 'add account'; $('email-acc-id').value = ''; $('email-acc-name').value = ''; $('email-acc-host').value = ''; $('email-acc-port').value = '993'; $('email-acc-encryption').value = 'ssl'; $('email-acc-username').value = ''; $('email-acc-password').value = ''; $('email-acc-folder').value = 'INBOX'; setAccountStatus(''); $('email-pw-hint').textContent = ''; } function editAccount(id) { const acc = accounts.find(a => a.id === id); if (!acc) return; editingAccountId = id; $('email-form-title').textContent = 'edit account: ' + acc.name; $('email-acc-id').value = id; $('email-acc-name').value = acc.name; $('email-acc-host').value = acc.host; $('email-acc-port').value = acc.port; $('email-acc-encryption').value = acc.encryption; $('email-acc-username').value = acc.username || ''; $('email-acc-password').value = ''; $('email-acc-folder').value = acc.folder; $('email-pw-hint').textContent = '(leave blank to keep existing)'; setAccountStatus(''); $('email-acc-name').focus(); } async function deleteAccount(id) { const acc = accounts.find(a => a.id === id); if (!acc) return; const ok = await hyprcrd.dialog.confirm( `Delete email account "${acc.name}"? Stored credentials will be removed but no mail is deleted from the server.`, { title: 'delete account', okLabel: 'Delete', cancelLabel: 'Cancel', danger: true } ); if (!ok) return; await hyprcrd.api(`api/email/accounts/${id}`, { method: 'DELETE' }); await loadAccounts(); renderAccountsList(); } async function saveAccount(e) { e.preventDefault(); setAccountStatus('saving...'); const payload = { name: $('email-acc-name').value.trim(), host: $('email-acc-host').value.trim(), port: parseInt($('email-acc-port').value) || 993, encryption: $('email-acc-encryption').value, username: $('email-acc-username').value, password: $('email-acc-password').value, folder: $('email-acc-folder').value.trim() || 'INBOX', }; if (!payload.name || !payload.host || !payload.username || (!editingAccountId && !payload.password)) { setAccountStatus('name, host, username, and password are required', 'error'); return; } if (editingAccountId && !payload.password) delete payload.password; try { let res; if (editingAccountId) { res = await hyprcrd.api(`api/email/accounts/${editingAccountId}`, { method: 'PATCH', body: payload }); } else { res = await hyprcrd.api('api/email/accounts', { method: 'POST', body: payload }); } if (res.error) { setAccountStatus('error: ' + res.error, 'error'); return; } setAccountStatus('saved', 'success'); await loadAccounts(); renderAccountsList(); setTimeout(() => { resetAccountForm(); }, 1500); } catch (err) { setAccountStatus('error: ' + err.message, 'error'); } } async function testExistingAccount(id) { setAccountStatus('testing...'); try { const res = await hyprcrd.api(`api/email/accounts/${id}/test`, { method: 'POST' }); if (res.ok) { setAccountStatus(`ok — ${res.exists} messages in INBOX`, 'success'); } else { setAccountStatus('failed: ' + (res.error || 'unknown'), 'error'); } } catch (err) { setAccountStatus('error: ' + err.message, 'error'); } } async function testCurrentForm() { if (!editingAccountId) { setAccountStatus('save first, then test'); return; } await testExistingAccount(editingAccountId); } // ── Messages ────────────────────────────────────────────────────── async function loadMessages() { if (!currentAccountId) return; const list = $('email-list'); list.innerHTML = '
'; $('email-status').textContent = 'fetching...'; const limit = getPageSize(); try { const res = await fetch(`api/email/messages?account=${currentAccountId}&limit=${limit}&offset=${currentOffset}`); const data = await res.json(); if (!data.ok) { list.innerHTML = `
${escapeHtml(data.error || 'failed to load')}
`; $('email-status').textContent = 'error'; renderPagination(); return; } currentMessages = data.messages || []; currentTotal = data.total ?? currentMessages.length; // If a delete pushed us past the end, slide back one page if (currentOffset > 0 && currentMessages.length === 0 && currentTotal > 0) { currentOffset = Math.max(0, currentOffset - limit); return loadMessages(); } renderMessageList(); renderPagination(); $('email-status').textContent = `${currentMessages.length} message${currentMessages.length === 1 ? '' : 's'}`; } catch (err) { list.innerHTML = `
${escapeHtml(err.message)}
`; $('email-status').textContent = 'error'; } } function renderPagination() { const tb = $('email-list-toolbar'); if (!tb) return; if (currentTotal === 0) { tb.classList.add('hidden'); tb.classList.remove('flex'); return; } tb.classList.remove('hidden'); tb.classList.add('flex'); const limit = getPageSize(); const startIdx = currentOffset + 1; const endIdx = Math.min(currentOffset + currentMessages.length, currentTotal); $('email-page-info').textContent = `${startIdx}-${endIdx} of ${currentTotal}`; const prevBtn = $('email-page-prev'); const nextBtn = $('email-page-next'); prevBtn.disabled = currentOffset <= 0; nextBtn.disabled = currentOffset + limit >= currentTotal; prevBtn.classList.toggle('opacity-30', prevBtn.disabled); prevBtn.classList.toggle('cursor-not-allowed', prevBtn.disabled); nextBtn.classList.toggle('opacity-30', nextBtn.disabled); nextBtn.classList.toggle('cursor-not-allowed', nextBtn.disabled); } function gotoPrevPage() { if (currentOffset <= 0) return; currentOffset = Math.max(0, currentOffset - getPageSize()); selectedUids.clear(); loadMessages(); } function gotoNextPage() { if (currentOffset + getPageSize() >= currentTotal) return; currentOffset += getPageSize(); selectedUids.clear(); loadMessages(); } function renderMessageList() { const list = $('email-list'); if (!currentMessages.length) { list.innerHTML = ''; renderSelectionBar(); renderSpamToolbar(); return; } list.innerHTML = currentMessages.map(m => { const sel = selectedUids.has(m.uid); const spamLevel = m.spam?.level || 'clean'; // 'clean' | 'suspicious' | 'spam' const spamScore = m.spam?.score ?? 0; const spamReasons = (m.spam?.reasons || []).join(' · '); const spamBadge = spamLevel === 'spam' ? `⚠ spam` : spamLevel === 'suspicious' ? `? suspicious` : ''; const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : ''; return `
`; }).join(''); list.querySelectorAll('.email-msg-row').forEach(row => { const uid = parseInt(row.dataset.uid); const cb = row.querySelector('.email-msg-check'); cb.addEventListener('click', e => e.stopPropagation()); // prevent row body click cb.addEventListener('change', () => toggleSelect(uid, cb.checked)); const content = row.querySelector('.email-msg-content'); content.addEventListener('mousedown', e => { if (e.button !== 0) return; dragActive = true; dragStartUid = uid; dragMoved = false; // If row is not selected → drag adds, if already selected → drag removes dragMode = selectedUids.has(uid) ? 'remove' : 'add'; e.preventDefault(); // suppress text selection / native dragstart }); content.addEventListener('mouseenter', () => { if (!dragActive) return; if (uid === dragStartUid) return; dragMoved = true; // First entry into another row → also apply mode to the start row applyDragToRow(dragStartUid); applyDragToRow(uid); }); }); renderSelectionBar(); renderSpamToolbar(); } function toggleSelect(uid, checked) { if (checked) selectedUids.add(uid); else selectedUids.delete(uid); const row = document.querySelector(`.email-msg-row[data-uid="${uid}"]`); row?.classList.toggle('selected', checked); const cb = row?.querySelector('.email-msg-check'); if (cb) cb.checked = checked; renderSelectionBar(); } /** Apply current drag mode (add/remove) to a single row by UID. */ function applyDragToRow(uid) { if (dragMode === 'add') { if (selectedUids.has(uid)) return; toggleSelect(uid, true); } else { if (!selectedUids.has(uid)) return; toggleSelect(uid, false); } } function renderSelectionBar() { const bar = $('email-selection-bar'); if (!bar) return; const n = selectedUids.size; if (n === 0) { bar.classList.add('hidden'); bar.classList.remove('flex'); } else { bar.classList.remove('hidden'); bar.classList.add('flex'); $('email-selection-count').textContent = `${n} selected`; } } // Selects both 'spam' and 'suspicious' — anything the heuristic flagged function isFlaggedAsSpam(m) { const lvl = m.spam?.level; return lvl === 'spam' || lvl === 'suspicious'; } function renderSpamToolbar() { const btn = $('email-select-spam'); if (!btn) return; const flaggedCount = currentMessages.filter(isFlaggedAsSpam).length; if (flaggedCount === 0) { btn.classList.add('hidden'); btn.classList.remove('flex'); } else { btn.classList.remove('hidden'); btn.classList.add('flex'); $('email-spam-count').textContent = flaggedCount; } } function selectAllSpam() { selectedUids.clear(); for (const m of currentMessages) { if (isFlaggedAsSpam(m)) selectedUids.add(m.uid); } // Re-render to apply checkbox + selected class renderMessageList(); } async function markSelectedAs(action) { if (selectedUids.size === 0) return; const n = selectedUids.size; const verb = action === 'spam' ? 'as spam' : 'as not spam'; const ok = await hyprcrd.dialog.confirm( `Mark ${n} sender${n === 1 ? '' : 's'} ${verb}? This adds ${action === 'spam' ? 'block' : 'allow'} rules that affect all future messages from the same senders.`, { title: `mark ${verb}`, okLabel: 'Mark', cancelLabel: 'Cancel', danger: action === 'spam' } ); if (!ok) return; $('email-status').textContent = `marking ${n}...`; try { const res = await hyprcrd.api('api/email/spam/mark', { method: 'POST', body: { account: currentAccountId, uids: [...selectedUids], action }, }); if (!res.ok) { $('email-status').textContent = 'error: ' + (res.error || 'mark failed'); return; } $('email-status').textContent = `${res.added} rule${res.added === 1 ? '' : 's'} added`; selectedUids.clear(); // Refresh so the new scoring is reflected immediately await loadMessages(); } catch (err) { $('email-status').textContent = 'error: ' + err.message; } } function clearSelection() { selectedUids.clear(); document.querySelectorAll('.email-msg-row.selected').forEach(r => r.classList.remove('selected')); document.querySelectorAll('.email-msg-check').forEach(c => c.checked = false); renderSelectionBar(); } async function deleteSelected({ skipConfirm = false } = {}) { if (selectedUids.size === 0) return; const n = selectedUids.size; if (!skipConfirm) { const ok = await hyprcrd.dialog.confirm( `Permanently delete ${n} message${n === 1 ? '' : 's'} from the server? This cannot be undone.`, { title: 'delete mail', okLabel: 'Delete', cancelLabel: 'Cancel', danger: true } ); if (!ok) return; } const uidsToDelete = [...selectedUids]; $('email-status').textContent = `deleting ${n}...`; try { const res = await hyprcrd.api('api/email/delete', { method: 'POST', body: { account: currentAccountId, uids: uidsToDelete }, }); if (!res.ok) { $('email-status').textContent = 'error: ' + (res.error || 'delete failed'); return; } // If the currently-open message was deleted, clear the body view if (currentMessage && uidsToDelete.includes(currentMessage.uid)) { currentMessage = null; $('email-body').classList.add('hidden'); $('email-body-empty').classList.remove('hidden'); } selectedUids.clear(); await loadMessages(); $('email-status').textContent = `deleted ${res.deleted}`; } catch (err) { $('email-status').textContent = 'error: ' + err.message; } } async function openMessage(uid) { document.querySelectorAll('.email-msg-row').forEach(r => r.classList.remove('active')); document.querySelector(`.email-msg-row[data-uid="${uid}"]`)?.classList.add('active'); $('email-body').classList.add('hidden'); $('email-body-empty').classList.remove('hidden'); $('email-status').textContent = 'opening...'; try { const res = await fetch(`api/email/message/${currentAccountId}/${uid}`); const data = await res.json(); if (!data.ok) { $('email-status').textContent = 'error: ' + (data.error || ''); return; } currentMessage = data; $('email-msg-subject').textContent = data.subject || '(no subject)'; $('email-msg-from').textContent = data.from || ''; $('email-msg-date').textContent = data.date || ''; $('email-extract-status').textContent = ''; // Always start in text view; HTML view is opt-in per message showMessageAsText(); // Toggle visibility of "view as html" button based on whether the // message actually has an HTML part const hasHtml = !!(data.html && data.html.trim()); const viewHtmlBtn = $('email-view-html'); viewHtmlBtn.classList.toggle('hidden', !hasHtml); viewHtmlBtn.classList.toggle('inline-flex', hasHtml); $('email-body-empty').classList.add('hidden'); $('email-body').classList.remove('hidden'); $('email-status').textContent = ''; } catch (err) { $('email-status').textContent = 'error'; } } // ── HTML view toggle ────────────────────────────────────────────── function showMessageAsText() { $('email-msg-text').textContent = currentMessage?.text || '(empty body)'; $('email-msg-text').classList.remove('hidden'); $('email-msg-html').classList.add('hidden'); $('email-view-text').classList.add('hidden'); $('email-view-text').classList.remove('inline-flex'); $('email-load-images').classList.add('hidden'); $('email-load-images').classList.remove('inline-flex'); if (currentMessage?.html?.trim()) { $('email-view-html').classList.remove('hidden'); $('email-view-html').classList.add('inline-flex'); } } /** * Render the message HTML inside a sandboxed iframe. * By default external images are blocked; pass loadImages=true to reveal them. */ function showMessageAsHtml(loadImages = false) { if (!currentMessage?.html) return; const iframe = $('email-msg-html'); // Sanitize first — DOMPurify is loaded globally by the core let safeHtml = (typeof DOMPurify !== 'undefined') ? DOMPurify.sanitize(currentMessage.html, { WHOLE_DOCUMENT: true, ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|cid):|#|\/|\.\.\/|\.\/)/i, FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link'], FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover'], }) : currentMessage.html; // Walk the parsed DOM to rewrite img src + count blocked const wrapper = document.createElement('div'); wrapper.innerHTML = safeHtml; let blockedCount = 0; if (!loadImages) { wrapper.querySelectorAll('img').forEach(img => { const src = img.getAttribute('src') || ''; // Allow inline data: and cid: refs (cid won't actually load anyway) if (src && !src.startsWith('data:') && !src.startsWith('cid:')) { img.setAttribute('data-blocked-src', src); img.removeAttribute('src'); img.setAttribute('alt', img.getAttribute('alt') || '[blocked image]'); img.style.cssText = 'display:inline-block;min-width:60px;min-height:30px;background:#eee;border:1px dashed #999;color:#666;padding:6px;font:10px sans-serif;text-align:center;'; blockedCount++; } }); // Also strip CSS background-image declarations from inline styles wrapper.querySelectorAll('[style*="background"]').forEach(el => { const s = el.getAttribute('style') || ''; if (/url\s*\(/i.test(s)) { el.setAttribute('style', s.replace(/background[^;]*url\s*\([^)]*\)[^;]*;?/gi, '')); blockedCount++; } }); } safeHtml = wrapper.innerHTML; // Wrap in a minimal HTML document with readable defaults const doc = ` ${safeHtml}`; iframe.srcdoc = doc; iframe.classList.remove('hidden'); $('email-msg-text').classList.add('hidden'); $('email-view-html').classList.add('hidden'); $('email-view-html').classList.remove('inline-flex'); $('email-view-text').classList.remove('hidden'); $('email-view-text').classList.add('inline-flex'); const loadBtn = $('email-load-images'); if (blockedCount > 0 && !loadImages) { $('email-blocked-count').textContent = blockedCount; loadBtn.classList.remove('hidden'); loadBtn.classList.add('inline-flex'); } else { loadBtn.classList.add('hidden'); loadBtn.classList.remove('inline-flex'); } } // ── Extract message → card ──────────────────────────────────────── async function extractMessage() { if (!currentMessage) return; const status = $('email-extract-status'); status.textContent = 'creating...'; status.style.color = 'var(--t-dim)'; const acc = accounts.find(a => a.id === currentAccountId); const accLabel = acc ? acc.name : 'mail'; const title = currentMessage.subject || '(no subject)'; const body = [ `**From:** ${currentMessage.from || ''}`, `**Date:** ${currentMessage.date || ''}`, `**Account:** ${accLabel}`, '', '---', '', currentMessage.text || '', '', '#email', ].join('\n'); try { const res = await hyprcrd.api('api/cards', { method: 'POST', body: { title, body, color: '#a78bfa' }, }); if (res.error) { status.textContent = 'error: ' + res.error; status.style.color = 'var(--t-danger)'; return; } status.textContent = 'card created'; status.style.color = 'var(--t-accent)'; window._reloadAll?.(); } catch (err) { status.textContent = 'error: ' + err.message; status.style.color = 'var(--t-danger)'; } } // ── Helpers ──────────────────────────────────────────────────────── function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function extractName(from) { const m = String(from).match(/^"?([^"<]+?)"?\s*<.+>$/); return m ? m[1].trim() : from; } function formatDate(rawDate) { if (!rawDate) return ''; const d = new Date(rawDate); if (isNaN(d.getTime())) return rawDate.substring(0, 16); const today = new Date(); if (d.toDateString() === today.toDateString()) { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } // ── Resizable divider ───────────────────────────────────────────── function initDivider() { const divider = $('email-divider'); const left = $('email-list-pane'); if (!divider || !left) return; let dragging = false; divider.addEventListener('mousedown', e => { dragging = true; e.preventDefault(); }); window.addEventListener('mousemove', e => { if (!dragging) return; const rect = $('email-view-mail').getBoundingClientRect(); const pct = ((e.clientX - rect.left) / rect.width) * 100; if (pct > 15 && pct < 75) left.style.width = pct + '%'; }); window.addEventListener('mouseup', () => { dragging = false; }); } // ── Header button injection ─────────────────────────────────────── function injectHeaderButton() { const header = document.querySelector('#main-header .flex.items-center.gap-3:last-of-type'); if (!header) return; if (document.getElementById('email-btn')) return; const btn = document.createElement('button'); btn.id = 'email-btn'; btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors'; btn.innerHTML = 'Mail'; btn.addEventListener('click', openEmail); const addBtn = document.getElementById('add-btn'); if (addBtn) header.insertBefore(btn, addBtn); else header.appendChild(btn); } // ── Init ────────────────────────────────────────────────────────── function init() { if (!$('email-overlay')) return; $('email-close').addEventListener('click', closeEmail); $('email-overlay').addEventListener('click', e => { if (e.target === $('email-overlay')) closeEmail(); }); $('email-overlay').addEventListener('keydown', e => { if (e.key === 'Escape') { closeEmail(); e.stopPropagation(); } }); $('email-account-select').addEventListener('change', e => { currentAccountId = parseInt(e.target.value); clearSelection(); currentOffset = 0; if (currentAccountId) loadMessages(); }); $('email-refresh').addEventListener('click', () => { clearSelection(); loadMessages(); }); $('email-page-prev').addEventListener('click', gotoPrevPage); $('email-page-next').addEventListener('click', gotoNextPage); $('email-show-settings').addEventListener('click', gotoAccountsSettings); $('email-welcome-configure').addEventListener('click', gotoAccountsSettings); $('email-extract-btn').addEventListener('click', extractMessage); $('email-view-html').addEventListener('click', () => showMessageAsHtml(false)); $('email-view-text').addEventListener('click', showMessageAsText); $('email-load-images').addEventListener('click', () => showMessageAsHtml(true)); $('email-selection-delete').addEventListener('click', deleteSelected); $('email-selection-clear').addEventListener('click', clearSelection); $('email-selection-mark-spam').addEventListener('click', () => markSelectedAs('spam')); $('email-selection-mark-ham').addEventListener('click', () => markSelectedAs('ham')); $('email-select-spam').addEventListener('click', selectAllSpam); initDivider(); injectHeaderButton(); // Backspace deletes selected mails (Shift+Backspace skips confirm) document.addEventListener('keydown', e => { if (e.key !== 'Backspace') return; if (!$('email-overlay')?.classList.contains('open')) return; if (selectedUids.size === 0) return; // Don't hijack while user is typing in a text-like input. // Checkboxes, radios, buttons etc. should NOT block backspace — // a focused row checkbox is the most common state when bulk-deleting. const ae = document.activeElement; if (ae) { if (ae.isContentEditable) return; if (ae.tagName === 'TEXTAREA') return; if (ae.tagName === 'INPUT') { const t = (ae.type || 'text').toLowerCase(); const textTypes = ['text', 'password', 'email', 'search', 'url', 'tel', 'number']; if (textTypes.includes(t)) return; } } e.preventDefault(); deleteSelected({ skipConfirm: e.shiftKey }); }); // Drag-select / click resolver: ends drag, decides click semantics window.addEventListener('mouseup', () => { if (!dragActive) return; const startUid = dragStartUid; const wasMoved = dragMoved; dragActive = false; dragStartUid = null; dragMoved = false; if (wasMoved) return; // drag-select already mutated state via mouseenter // Pure click on a row body if (selectedUids.size > 0) { // In selection mode → toggle this row in/out of selection toggleSelect(startUid, !selectedUids.has(startUid)); } else { // Nothing selected → open the message openMessage(startUid); } }); // Register the "mail" tab inside the core settings overlay hyprcrd.settings.registerTab({ id: 'email', label: 'mail', icon: 'ph-envelope', onShow: (panelEl) => refreshSettingsPanel(panelEl), }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } hyprcrd.registerPlugin({ name: 'email', version: '1.0.0', description: 'IMAP mail viewer with card extraction', });