Files
hyprcrd/public/plugins/email/email.js
T
thandClaude Opus 4.6 f465306c1d global tooltip system, migrate all title attrs to data-tip/data-help
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>
2026-04-12 19:55:17 +02:00

1005 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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 =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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',
});