email plugin

This commit is contained in:
th committed 2026-04-10 10:42:18 +02:00
1 parent 3f4ba3c0f4
commit cc97754ae6
16 files changed
+2979 -5

No files matched your search

+959
View File
@@ -0,0 +1,959 @@
// ── 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>
<span id="email-acc-status" class="font-pixel text-[9px] text-t-dim"></span>
</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" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
<button class="toolbar-btn email-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
<button class="toolbar-btn email-del-btn" title="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 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';
$('email-acc-status').textContent = '';
$('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)';
$('email-acc-status').textContent = '';
$('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();
const status = $('email-acc-status');
status.textContent = 'saving...';
status.style.color = 'var(--t-dim)';
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)) {
status.textContent = 'name, host, username, and password are required';
status.style.color = 'var(--t-danger)';
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) {
status.textContent = 'error: ' + res.error;
status.style.color = 'var(--t-danger)';
return;
}
status.textContent = 'saved';
status.style.color = 'var(--t-accent)';
await loadAccounts();
renderAccountsList();
resetAccountForm();
} catch (err) {
status.textContent = 'error: ' + err.message;
status.style.color = 'var(--t-danger)';
}
}
async function testExistingAccount(id) {
const status = $('email-acc-status');
status.textContent = 'testing...';
status.style.color = 'var(--t-dim)';
try {
const res = await hyprcrd.api(`api/email/accounts/${id}/test`, { method: 'POST' });
if (res.ok) {
status.textContent = `ok — ${res.exists} messages in INBOX`;
status.style.color = 'var(--t-accent)';
} else {
status.textContent = 'failed: ' + (res.error || 'unknown');
status.style.color = 'var(--t-danger)';
}
} catch (err) {
status.textContent = 'error: ' + err.message;
status.style.color = 'var(--t-danger)';
}
}
async function testCurrentForm() {
if (!editingAccountId) {
const status = $('email-acc-status');
status.textContent = 'save first, then test';
status.style.color = 'var(--t-dim)';
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">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" title="${escapeHtml(spamReasons)}">⚠ spam</span>`
: spamLevel === 'suspicious'
? `<span class="email-spam-badge suspicious" title="${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}" title="${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();
});
$('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',
});