New tooltip.js module with styled tooltips (data-tip) and multi-line inline help (data-help). Migrates all ~70 native title attributes across index.php, JS modules, and all plugins. Adds inline help on 9 strategic UI elements (split, superview, zen, extract, assets, etc). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1005 lines
43 KiB
JavaScript
1005 lines
43 KiB
JavaScript
// ── 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 => `<option value="${a.id}">${escapeHtml(a.name)}</option>`).join('')
|
||
: '<option value="">— no accounts —</option>';
|
||
}
|
||
|
||
// ── Settings tab: rendering ───────────────────────────────────────
|
||
|
||
function buildSettingsPanel(panelEl) {
|
||
panelEl.innerHTML = `
|
||
<div class="space-y-4">
|
||
<div>
|
||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-sliders text-xs"></i> preferences</div>
|
||
<div class="flex items-center gap-3">
|
||
<label class="font-pixel text-[9px] text-t-muted uppercase">messages per page</label>
|
||
<input id="email-page-size" type="number" min="5" max="200" value="${getPageSize()}" class="w-20 bg-t-bg border border-t-border text-t-bright px-2 py-1 text-xs focus:border-t-accent focus:outline-none font-mono text-center">
|
||
<span id="email-page-size-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||
</div>
|
||
<p class="text-[10px] text-t-dim mt-1">Number of mails fetched per page (5–200). Affects all accounts.</p>
|
||
</div>
|
||
<div class="border-t border-t-border pt-4">
|
||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-envelope text-xs"></i> mail accounts</div>
|
||
<p class="text-[10px] text-t-dim leading-relaxed mb-3">
|
||
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.
|
||
</p>
|
||
<div id="email-accounts-list" class="space-y-2"></div>
|
||
</div>
|
||
<div class="border-t border-t-border pt-4">
|
||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><span id="email-form-title">add account</span></div>
|
||
<form id="email-account-form" class="space-y-2">
|
||
<input type="hidden" id="email-acc-id" value="">
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<label class="block">
|
||
<span class="font-pixel text-[9px] text-t-dim uppercase">name</span>
|
||
<input id="email-acc-name" type="text" placeholder="My Mail" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||
</label>
|
||
<label class="block">
|
||
<span class="font-pixel text-[9px] text-t-dim uppercase">folder</span>
|
||
<input id="email-acc-folder" type="text" placeholder="INBOX" value="INBOX" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||
</label>
|
||
</div>
|
||
<div class="grid grid-cols-3 gap-2">
|
||
<label class="block col-span-2">
|
||
<span class="font-pixel text-[9px] text-t-dim uppercase">imap host</span>
|
||
<input id="email-acc-host" type="text" placeholder="imap.example.com" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||
</label>
|
||
<label class="block">
|
||
<span class="font-pixel text-[9px] text-t-dim uppercase">port</span>
|
||
<input id="email-acc-port" type="number" value="993" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||
</label>
|
||
</div>
|
||
<label class="block">
|
||
<span class="font-pixel text-[9px] text-t-dim uppercase">encryption</span>
|
||
<select id="email-acc-encryption" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||
<option value="ssl">SSL/TLS (port 993)</option>
|
||
<option value="none">None (plain, port 143)</option>
|
||
</select>
|
||
</label>
|
||
<label class="block">
|
||
<span class="font-pixel text-[9px] text-t-dim uppercase">username</span>
|
||
<input id="email-acc-username" type="text" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||
</label>
|
||
<label class="block">
|
||
<span class="font-pixel text-[9px] text-t-dim uppercase">password / app password <span id="email-pw-hint" class="text-t-muted normal-case text-[8px]"></span></span>
|
||
<input id="email-acc-password" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||
</label>
|
||
<div class="flex items-center gap-2 pt-1">
|
||
<button type="submit" id="email-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
||
<button type="button" id="email-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
||
<button type="button" id="email-acc-cancel" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Cancel</span></button>
|
||
</div>
|
||
<div id="email-acc-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 = '<p class="text-xs text-t-dim italic">No accounts configured yet. Use the form below to add one.</p>';
|
||
return;
|
||
}
|
||
list.innerHTML = accounts.map(a => `
|
||
<div class="email-account-row" data-id="${a.id}">
|
||
<i class="ph ph-envelope text-t-accent text-base"></i>
|
||
<div class="email-account-info">
|
||
<div class="email-account-name">${escapeHtml(a.name)}</div>
|
||
<div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div>
|
||
</div>
|
||
<div class="email-account-actions">
|
||
<button class="toolbar-btn email-edit-btn" data-tip="edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||
<button class="toolbar-btn email-test-btn" data-tip="test"><i class="ph ph-plug text-sm"></i></button>
|
||
<button class="toolbar-btn email-del-btn" data-tip="delete"><i class="ph ph-trash text-sm"></i></button>
|
||
</div>
|
||
</div>
|
||
`).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 = '<div class="email-loading-spinner flex items-center justify-center" style="min-height:60vh;"><i class="ph ph-spinner card-spinner" style="font-size:2.25rem;color:var(--t-muted);opacity:0.4;"></i></div>';
|
||
$('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 = `<div class="p-4 text-xs text-t-danger">${escapeHtml(data.error || 'failed to load')}</div>`;
|
||
$('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 = `<div class="p-4 text-xs text-t-danger">${escapeHtml(err.message)}</div>`;
|
||
$('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 = '<div class="p-4 text-xs text-t-dim italic cursor-blink">no messages</div>';
|
||
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'
|
||
? `<span class="email-spam-badge spam" data-tip="${escapeHtml(spamReasons)}">⚠ spam</span>`
|
||
: spamLevel === 'suspicious'
|
||
? `<span class="email-spam-badge suspicious" data-tip="${escapeHtml(spamReasons)}">? suspicious</span>`
|
||
: '';
|
||
const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : '';
|
||
return `
|
||
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" data-tip="${escapeHtml(rowTitle)}">
|
||
<input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message">
|
||
<div class="email-msg-content">
|
||
<div class="flex items-baseline justify-between gap-2">
|
||
<span class="email-msg-from">${escapeHtml(extractName(m.from))}</span>
|
||
<span class="email-msg-date">${escapeHtml(formatDate(m.date))}</span>
|
||
</div>
|
||
<div class="email-msg-subject">${spamBadge}${escapeHtml(m.subject || '(no subject)')}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).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 = `<!doctype html><html><head><meta charset="utf-8">
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; line-height: 1.5; color: #222; padding: 14px; margin: 0; word-wrap: break-word; }
|
||
img { max-width: 100%; height: auto; }
|
||
a { color: #2563eb; }
|
||
table { border-collapse: collapse; max-width: 100%; }
|
||
td, th { padding: 4px 6px; }
|
||
blockquote { border-left: 3px solid #ccc; margin: 8px 0 8px 0; padding-left: 10px; color: #555; }
|
||
pre, code { font-family: ui-monospace, monospace; background: #f4f4f4; padding: 2px 4px; border-radius: 2px; }
|
||
* { max-width: 100%; box-sizing: border-box; }
|
||
</style>
|
||
</head><body>${safeHtml}</body></html>`;
|
||
|
||
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 = '<i class="ph ph-envelope text-sm"></i><span class="font-pixel text-[10px]">Mail</span>';
|
||
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();
|
||
});
|
||
// Document-level Escape: works regardless of which element is focused
|
||
// (the previous overlay-level keydown only fired when focus was inside
|
||
// the overlay, which is rarely the case after open).
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'Escape') return;
|
||
if (!$('email-overlay')?.classList.contains('open')) return;
|
||
// Don't steal Escape from a sub-modal that lives over the overlay
|
||
const dialog = document.getElementById('dialog');
|
||
if (dialog?.classList.contains('open')) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
closeEmail();
|
||
});
|
||
// Document-level "m" toggles the mail overlay. Suppressed when the
|
||
// user is typing into an input / textarea / contenteditable so it
|
||
// doesn't intercept normal text entry. Also suppressed if any modal
|
||
// overlay is open (editor, search, settings, …) so we don't fight
|
||
// those for the hotkey.
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key !== 'm' && e.key !== 'M') return;
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
const ae = document.activeElement;
|
||
if (ae) {
|
||
if (ae.isContentEditable) return;
|
||
const tag = ae.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||
}
|
||
// Don't toggle while another full-screen overlay is in front
|
||
const blockers = ['modal', 'detail', 'search', 'settings-overlay', 'gallery', 'help', 'calendar', 'tasks', 'dialog'];
|
||
for (const id of blockers) {
|
||
if (document.getElementById(id)?.classList.contains('open')) return;
|
||
}
|
||
e.preventDefault();
|
||
const overlay = $('email-overlay');
|
||
if (overlay?.classList.contains('open')) closeEmail();
|
||
else openEmail();
|
||
});
|
||
|
||
$('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',
|
||
});
|