email plugin
This commit is contained in:
1 parent
3f4ba3c0f4
commit
cc97754ae6
16 files changed
+2979
-5
No files matched your search
Submodule
+1
Submodule gitea-issue-client added at 3d0c2f3a82.
@@ -239,6 +239,10 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
|
return implode(',', array_unique(array_map('strtolower', $matches[1] ?? [])));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin route handlers (registered by plugin backend.php files).
|
||||||
|
// Each handler is responsible for calling exit() on a match.
|
||||||
|
PluginManager::instance()->dispatchRoute($route, $method);
|
||||||
|
|
||||||
// Cards
|
// Cards
|
||||||
if ($route === '/api/cards' && $method === 'POST') {
|
if ($route === '/api/cards' && $method === 'POST') {
|
||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|||||||
+4
-3
@@ -170,7 +170,7 @@ document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
|||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
const res = await fetch('api/auth/status');
|
const res = await fetch('api/auth/status');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.csrf) setCsrf(data.csrf);
|
if (data.csrf) { setCsrf(data.csrf); window.hyprcrd?.util?.setCsrf?.(data.csrf); }
|
||||||
|
|
||||||
if (data.setup) {
|
if (data.setup) {
|
||||||
let pw = null;
|
let pw = null;
|
||||||
@@ -181,7 +181,7 @@ async function checkAuth() {
|
|||||||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.error) { location.reload(); return; }
|
if (d.error) { location.reload(); return; }
|
||||||
if (d.csrf) setCsrf(d.csrf);
|
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||||
} else if (!data.logged_in) {
|
} else if (!data.logged_in) {
|
||||||
let ok = false;
|
let ok = false;
|
||||||
while (!ok) {
|
while (!ok) {
|
||||||
@@ -189,7 +189,8 @@ async function checkAuth() {
|
|||||||
if (!pw) continue;
|
if (!pw) continue;
|
||||||
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.ok) { ok = true; if (d.csrf) setCsrf(d.csrf); }
|
if (d.ok) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } }
|
||||||
|
else { /* failed login: caller will retry */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Util helpers (mirror of internal util.js) ───────────────────
|
// ── Util helpers (mirror of internal util.js) ───────────────────
|
||||||
|
let csrfToken = '';
|
||||||
const util = {
|
const util = {
|
||||||
$(idOrSel) {
|
$(idOrSel) {
|
||||||
if (typeof idOrSel !== 'string') return null;
|
if (typeof idOrSel !== 'string') return null;
|
||||||
@@ -114,6 +115,105 @@
|
|||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
}[c]));
|
}[c]));
|
||||||
},
|
},
|
||||||
|
// CSRF: core calls setCsrf() after auth so plugins can read it
|
||||||
|
setCsrf(token) { csrfToken = String(token || ''); },
|
||||||
|
csrf() { return csrfToken; },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Generic JSON API call with automatic CSRF header. */
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings registry ───────────────────────────────────────────
|
||||||
|
// Plugins can inject their own tabs into the main settings overlay.
|
||||||
|
// The plugin's onShow callback is called whenever the tab becomes visible.
|
||||||
|
const settings = {
|
||||||
|
registerTab({ id, label, icon, onShow }) {
|
||||||
|
if (!id) return;
|
||||||
|
const nav = document.getElementById('settings-nav');
|
||||||
|
const panelsContainer = document.querySelector('#settings-overlay .overflow-y-auto.p-5');
|
||||||
|
if (!nav || !panelsContainer) {
|
||||||
|
// DOM not ready — defer to next tick
|
||||||
|
requestAnimationFrame(() => settings.registerTab({ id, label, icon, onShow }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nav.querySelector(`[data-tab="${id}"]`)) return; // already registered
|
||||||
|
|
||||||
|
// Create tab button
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'settings-tab';
|
||||||
|
btn.dataset.tab = id;
|
||||||
|
const iconClass = icon || 'ph-puzzle-piece';
|
||||||
|
btn.innerHTML = `<i class="ph ${iconClass} text-sm"></i><span class="font-pixel text-[10px]">${label || id}</span>`;
|
||||||
|
nav.appendChild(btn);
|
||||||
|
|
||||||
|
// Create empty panel container
|
||||||
|
let panel = document.getElementById(`settings-panel-${id}`);
|
||||||
|
if (!panel) {
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.id = `settings-panel-${id}`;
|
||||||
|
panel.className = 'hidden';
|
||||||
|
panelsContainer.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click handler — duplicates core tab logic (theme.js may also
|
||||||
|
// attach its own handler later, but identical work is harmless).
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
nav.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
if (typeof onShow === 'function') {
|
||||||
|
try { onShow(panel); } catch (err) { console.error(`[settings tab ${id}]`, err); }
|
||||||
|
}
|
||||||
|
hooks.emit('settings.tab.shown', { tab: id, panel });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Open the main settings overlay. */
|
||||||
|
open() {
|
||||||
|
document.getElementById('settings-btn')?.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Open the settings overlay and switch to a specific tab. */
|
||||||
|
openTab(id) {
|
||||||
|
this.open();
|
||||||
|
// Settings opens via click; the tab might need a frame to render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelector(`#settings-nav [data-tab="${id}"]`)?.click();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Dialog API (filled in by core dialog.js after load) ─────────
|
||||||
|
// Plugins call hyprcrd.dialog.confirm(...) / hyprcrd.dialog.show(...)
|
||||||
|
// instead of native browser confirm()/prompt() so the UI stays themed
|
||||||
|
// and consistent.
|
||||||
|
const dialog = {
|
||||||
|
show: null, // (opts) => Promise<value|null> — set by core dialog.js
|
||||||
|
/** Convenience confirm wrapper. Returns Promise<true|null>. */
|
||||||
|
async confirm(message, opts = {}) {
|
||||||
|
if (typeof dialog.show !== 'function') {
|
||||||
|
// Core dialog not loaded yet — fall back to native
|
||||||
|
return window.confirm(message) ? true : null;
|
||||||
|
}
|
||||||
|
return dialog.show({
|
||||||
|
title: opts.title || 'confirm',
|
||||||
|
message,
|
||||||
|
input: false,
|
||||||
|
danger: opts.danger ?? true,
|
||||||
|
okLabel: opts.okLabel || 'ok',
|
||||||
|
cancelLabel: opts.cancelLabel || 'cancel',
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Plugin metadata registry ────────────────────────────────────
|
// ── Plugin metadata registry ────────────────────────────────────
|
||||||
@@ -128,6 +228,9 @@
|
|||||||
editor,
|
editor,
|
||||||
markdown,
|
markdown,
|
||||||
util,
|
util,
|
||||||
|
api,
|
||||||
|
settings,
|
||||||
|
dialog,
|
||||||
plugins,
|
plugins,
|
||||||
registerPlugin,
|
registerPlugin,
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ export function showDialog({ title, message = '', input = false, placeholder = '
|
|||||||
|
|
||||||
export function isDialogOpen() { return overlay.classList.contains('open'); }
|
export function isDialogOpen() { return overlay.classList.contains('open'); }
|
||||||
|
|
||||||
|
// Expose to plugins via the hyprcrd API
|
||||||
|
if (typeof window !== 'undefined' && window.hyprcrd?.dialog) {
|
||||||
|
window.hyprcrd.dialog.show = showDialog;
|
||||||
|
}
|
||||||
|
|
||||||
okBtn.addEventListener('click', submit);
|
okBtn.addEventListener('click', submit);
|
||||||
cancelBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
cancelBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
||||||
if (closeBtn) closeBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
if (closeBtn) closeBtn.addEventListener('click', () => { if (canCancel()) close(null); });
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# hyprcrd plugin system — mindmap
|
||||||
|
|
||||||
|
A complete architectural reference. Don't lose state on this — every detail
|
||||||
|
here was learned the hard way during the canvas/tests/email plugin builds.
|
||||||
|
|
||||||
|
```
|
||||||
|
hyprcrd plugin system
|
||||||
|
│
|
||||||
|
├── Discovery (PHP)
|
||||||
|
│ ├── src/PluginManager.php (singleton)
|
||||||
|
│ │ ├── scan(dir) → reads each subdir's plugin.json
|
||||||
|
│ │ ├── loadDisabled(stateFile) → reads data/plugins.json {disabled: [...]}
|
||||||
|
│ │ ├── loadBackends() → require_once enabled backend.php files
|
||||||
|
│ │ └── isEnabled(name) → checked by cssLinks/htmlFragments/scriptTags
|
||||||
|
│ └── src/bootstrap.php (call order matters!)
|
||||||
|
│ 1. config.php
|
||||||
|
│ 2. Database.php
|
||||||
|
│ 3. PluginManager.php
|
||||||
|
│ 4. PluginManager::scan(public/plugins)
|
||||||
|
│ 5. PluginManager::loadDisabled(dirname(DB_PATH).'/plugins.json')
|
||||||
|
│ 6. PluginManager::loadBackends() ← AFTER loadDisabled, so disabled plugins skip backend.php too
|
||||||
|
│
|
||||||
|
├── plugin.json manifest format
|
||||||
|
│ ├── name (string, unique)
|
||||||
|
│ ├── version (string)
|
||||||
|
│ ├── description (string)
|
||||||
|
│ ├── frontend
|
||||||
|
│ │ ├── js: [...] (loaded as <script type="module">)
|
||||||
|
│ │ ├── css: [...] (loaded as <link>)
|
||||||
|
│ │ └── html: [...] (file contents inlined into body via htmlFragments())
|
||||||
|
│ └── backend (string, optional path to backend.php)
|
||||||
|
│
|
||||||
|
├── Core injection points (PHP, in index.php)
|
||||||
|
│ ├── In <head> after style.css: <?= PluginManager::instance()->cssLinks() ?>
|
||||||
|
│ ├── In #md-toolbar after extract: <?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||||
|
│ ├── Before </body>: <?= PluginManager::instance()->htmlFragments() ?>
|
||||||
|
│ ├── Then synchronously: <script src="js/hyprcrd.js"></script>
|
||||||
|
│ ├── Then plugin entries: <?= PluginManager::instance()->scriptTags() ?>
|
||||||
|
│ ├── Then core ES module: <script type="module" src="js/app.js">
|
||||||
|
│ └── Inside auth-protected API block, BEFORE first /api/cards check:
|
||||||
|
│ PluginManager::instance()->dispatchRoute($route, $method);
|
||||||
|
│
|
||||||
|
├── Frontend API: window.hyprcrd (defined in public/js/hyprcrd.js, sync-loaded)
|
||||||
|
│ │
|
||||||
|
│ ├── hooks (generic event bus)
|
||||||
|
│ │ ├── on(name, fn) register listener
|
||||||
|
│ │ ├── off(name, fn) remove
|
||||||
|
│ │ └── emit(name, data) → data listeners can mutate via return value
|
||||||
|
│ │
|
||||||
|
│ ├── editor (toolbar command registry)
|
||||||
|
│ │ ├── registerCommand(name, fn) buttons with data-md="<name>" dispatch here
|
||||||
|
│ │ ├── unregisterCommand(name)
|
||||||
|
│ │ ├── getCommand(name)
|
||||||
|
│ │ └── api ← set by editor.js AFTER its init
|
||||||
|
│ │ ├── body the textarea element
|
||||||
|
│ │ ├── insertMd(b, a, ph) inserts markdown around selection
|
||||||
|
│ │ └── updatePreview() re-renders the preview pane
|
||||||
|
│ │
|
||||||
|
│ ├── markdown (registry, applied in order)
|
||||||
|
│ │ ├── registerFencedBlock(lang, fn) ```<lang> blocks → handler returns HTML
|
||||||
|
│ │ ├── registerPreProcessor(fn) runs on source text before marked.parse()
|
||||||
|
│ │ ├── registerPostProcessor(fn) runs on HTML after marked.parse(), before DOMPurify
|
||||||
|
│ │ ├── getFencedBlocks()
|
||||||
|
│ │ ├── getPreProcessors()
|
||||||
|
│ │ └── getPostProcessors()
|
||||||
|
│ │ └── handler signature: fn(content, { esc, normalizeListBlock }) → htmlString
|
||||||
|
│ │
|
||||||
|
│ ├── settings (settings overlay tab registry)
|
||||||
|
│ │ ├── registerTab({id, label, icon, onShow})
|
||||||
|
│ │ │ ├── injects button into #settings-nav
|
||||||
|
│ │ │ ├── creates empty <div id="settings-panel-{id}"> in panels container
|
||||||
|
│ │ │ ├── attaches click handler (active state + emit hook + onShow)
|
||||||
|
│ │ │ └── icon = phosphor class like 'ph-envelope', defaults to ph-puzzle-piece
|
||||||
|
│ │ ├── open() clicks #settings-btn
|
||||||
|
│ │ └── openTab(id) opens settings + switches to that tab
|
||||||
|
│ │
|
||||||
|
│ ├── util
|
||||||
|
│ │ ├── $(idOrSel) querySelector or getElementById
|
||||||
|
│ │ ├── $$(sel) querySelectorAll → Array
|
||||||
|
│ │ ├── esc(s) HTML escape
|
||||||
|
│ │ ├── setCsrf(token) called by app.js after auth
|
||||||
|
│ │ └── csrf() read by plugins for API calls
|
||||||
|
│ │
|
||||||
|
│ ├── api(path, opts) fetch wrapper, auto X-CSRF-Token header
|
||||||
|
│ │
|
||||||
|
│ ├── dialog (themed confirm/prompt — NEVER use native confirm())
|
||||||
|
│ │ ├── show(opts) → core dialog.js fills this in after load
|
||||||
|
│ │ │ opts: {title, message, input, danger, okLabel, cancelLabel}
|
||||||
|
│ │ │ returns Promise<value|null> (null = cancelled)
|
||||||
|
│ │ └── confirm(msg, opts) convenience: returns Promise<true|null>
|
||||||
|
│ │ opts: {title, okLabel, cancelLabel, danger}
|
||||||
|
│ │
|
||||||
|
│ └── registerPlugin({name, version, description})
|
||||||
|
│ └── pushes to hyprcrd.plugins[] for the settings → plugins UI
|
||||||
|
│
|
||||||
|
├── Backend API extension (PHP, from plugin's backend.php)
|
||||||
|
│ └── PluginManager::instance()->onApiRoute(function($route, $method) {
|
||||||
|
│ if ($route === '/api/foo' && $method === 'GET') {
|
||||||
|
│ // ... must call exit() on match
|
||||||
|
│ echo json_encode([...]);
|
||||||
|
│ exit;
|
||||||
|
│ }
|
||||||
|
│ });
|
||||||
|
│ │
|
||||||
|
│ ├── handlers run in registration order, before core route handling
|
||||||
|
│ ├── pass-through if none exit → core routes get a chance
|
||||||
|
│ ├── CSRF middleware in core runs BEFORE dispatchRoute, so plugin
|
||||||
|
│ │ routes are automatically CSRF-protected for mutations
|
||||||
|
│ └── auth check also runs BEFORE → all plugin routes require login
|
||||||
|
│
|
||||||
|
├── Lifecycle / load order (CRITICAL for timing bugs)
|
||||||
|
│ 1. Browser parses HTML
|
||||||
|
│ 2. <link rel="stylesheet"> tags loaded (core + plugin CSS)
|
||||||
|
│ 3. <?= htmlFragments() ?> → plugin overlay HTML now in DOM
|
||||||
|
│ 4. <script src="js/hyprcrd.js"> → SYNCHRONOUS, window.hyprcrd defined
|
||||||
|
│ 5. <script type="module" src="plugins/.../*.js"> → DEFERRED, run after parse
|
||||||
|
│ ├── Plugin modules execute in document order
|
||||||
|
│ ├── At this point: DOM ready, window.hyprcrd ready, core editor.js NOT loaded yet
|
||||||
|
│ ├── So hyprcrd.editor.api is null until app.js runs
|
||||||
|
│ └── Plugin commands that USE editor.api should defer (only fire on user click)
|
||||||
|
│ 6. <script type="module" src="js/app.js"> → orchestrator
|
||||||
|
│ ├── imports core modules (state, theme, editor, markdown, ...)
|
||||||
|
│ ├── markdown.js top-level: registers gallery/tree/bullets via the SAME registry
|
||||||
|
│ ├── editor.js top-level: registers heading/bold/italic/etc + sets editor.api
|
||||||
|
│ └── checkAuth() → setCsrf() → window.hyprcrd.util.setCsrf() (mirrored)
|
||||||
|
│
|
||||||
|
├── Settings tab race (avoided by registerTab design)
|
||||||
|
│ ├── theme.js attaches click handlers to all .settings-tab at module load
|
||||||
|
│ ├── If plugin's tab is added BEFORE theme.js runs (default), theme.js handler ALSO attaches
|
||||||
|
│ ├── Result: both core handler AND plugin click handler fire on click
|
||||||
|
│ ├── Both manage active states identically — harmless duplicate work
|
||||||
|
│ └── Plugin's onShow is called by the plugin handler, NOT by core
|
||||||
|
│
|
||||||
|
├── Disable mechanism
|
||||||
|
│ ├── State file: data/plugins.json → {"disabled": ["canvas", ...]}
|
||||||
|
│ ├── Settings UI: settings → plugins → toggle switches
|
||||||
|
│ ├── API: GET /api/plugins, POST /api/plugins/toggle {name, enabled}
|
||||||
|
│ ├── Disabled plugins are skipped in cssLinks, htmlFragments, scriptTags, AND loadBackends
|
||||||
|
│ ├── Toggling requires page reload (already-loaded code can't unload)
|
||||||
|
│ └── Reload notice in plugins panel; dirtyAfterToggle flag persists until reload
|
||||||
|
│
|
||||||
|
├── Conventions
|
||||||
|
│ ├── Plugins NEVER import from core modules. Use window.hyprcrd globals only.
|
||||||
|
│ ├── Plugin JS is ES module (loaded as type="module") so import/export works WITHIN the plugin
|
||||||
|
│ ├── Plugin self-registration happens at module top level (no init function needed)
|
||||||
|
│ ├── Plugin DOM injection should idempotently check if element already exists
|
||||||
|
│ │ (canvas: btn.querySelector('[data-md="asciiart"]') guard)
|
||||||
|
│ ├── If a plugin needs DOM that may not exist yet (e.g. injectToolbarButton),
|
||||||
|
│ │ wrap in DOMContentLoaded check OR run immediately if document.readyState !== 'loading'
|
||||||
|
│ ├── Plugin schema migrations live in backend.php as IIFE that runs once per request
|
||||||
|
│ │ (cheap CREATE TABLE IF NOT EXISTS)
|
||||||
|
│ └── Plugin credentials → reuse vault key encryption (sha256(vault_key) substr 0..32 + AES-256-CBC)
|
||||||
|
│
|
||||||
|
├── Existing plugins
|
||||||
|
│ ├── canvas/ (~500 lines)
|
||||||
|
│ │ ├── No backend
|
||||||
|
│ │ ├── Registers ascii fenced block + asciiart editor command + toolbar btn (JS injected)
|
||||||
|
│ │ ├── Has its own overlay (canvas.html), tools, palette
|
||||||
|
│ │ └── Edit-mode: detects cursor inside ```ascii block, loads existing content
|
||||||
|
│ ├── tests/ (~40 test files + runner.js + loader.js)
|
||||||
|
│ │ ├── No backend
|
||||||
|
│ │ ├── loader.js conditionally imports tests.js on ?test or Ctrl+Shift+T
|
||||||
|
│ │ ├── Test files import from '../../js/modules/...' (NOT '../modules/...')
|
||||||
|
│ │ └── Both static AND dynamic imports use that path — sed both when moving files
|
||||||
|
│ └── email/ (~1300 lines, biggest)
|
||||||
|
│ ├── Backend: ImapClient.php (pure-PHP IMAP4) + backend.php (table + routes)
|
||||||
|
│ ├── Schema: email_accounts table, passwords AES-256-CBC with vault key
|
||||||
|
│ ├── Routes: /api/email/{accounts, accounts/{id}, accounts/{id}/test, messages, message/{accId}/{uid}}
|
||||||
|
│ ├── Settings tab: registered via hyprcrd.settings.registerTab({id:'email', label:'mail'})
|
||||||
|
│ │ └── onShow rebuilds form on first show, refreshes accounts list every show
|
||||||
|
│ ├── Welcome view: shown when accounts.length === 0, "configure accounts" → openTab('email')
|
||||||
|
│ ├── Header button "mail" injected via JS into #main-header right group, before #add-btn
|
||||||
|
│ └── Card extraction: POST /api/cards with #email tag, calls window._reloadAll()
|
||||||
|
│
|
||||||
|
├── Gotchas / lessons learned
|
||||||
|
│ ├── statusHeight vs scrollHeight — textarea.scrollHeight includes padding, mind it
|
||||||
|
│ ├── inline-block vs block — pre with display:block stretches to container width,
|
||||||
|
│ │ making mouse-cell math wrong. Use width:max-content.
|
||||||
|
│ ├── auto-fit canvas — measure ONE char (set textContent='M'), then floor(container/charW)
|
||||||
|
│ ├── DOMPurify strips HTML comments — list:tree/bullets uses comments as markers, then
|
||||||
|
│ │ converts to classes BEFORE sanitize
|
||||||
|
│ ├── ALLOWED_TAGS in markdown.js — must include any tag plugins emit (pre, span, div ok)
|
||||||
|
│ ├── ALLOWED_ATTR — class, style, data-* are allowed; check before adding novel attrs
|
||||||
|
│ ├── theme.js handles tab switching for ALL .settings-tab, including dynamically added ones,
|
||||||
|
│ │ because forEach runs at module load AFTER plugins have injected their tabs
|
||||||
|
│ ├── CSRF token sync — app.js stores it via setCsrf() in util.js (module-private),
|
||||||
|
│ │ also calls window.hyprcrd.util.setCsrf() so plugins can read it
|
||||||
|
│ ├── Plugin tests can break core tab counts — don't hardcode toBe(N), use toContain checks
|
||||||
|
│ └── Header button injection — querySelector('#main-header .flex.items-center.gap-3:last-of-type')
|
||||||
|
│ to grab the right-side button group (calendar, gallery, etc.)
|
||||||
|
│
|
||||||
|
└── Adding a new plugin (checklist)
|
||||||
|
1. mkdir public/plugins/<name>
|
||||||
|
2. Write plugin.json (name, version, description, frontend.{js,css,html}, backend?)
|
||||||
|
3. If JS: import nothing from core. Use window.hyprcrd.* exclusively.
|
||||||
|
4. If HTML: provide overlay/modal markup. Use IDs prefixed with plugin name to avoid clashes.
|
||||||
|
5. If CSS: scope selectors to plugin-specific classes/IDs.
|
||||||
|
6. If backend: require_once your classes, define helpers in named functions (no closures
|
||||||
|
at top level — they pollute the global scope), register routes via onApiRoute().
|
||||||
|
7. If schema: CREATE TABLE IF NOT EXISTS in an IIFE at top of backend.php.
|
||||||
|
8. If credentials: reuse the vault key encryption pattern (see email/backend.php).
|
||||||
|
9. If settings: hyprcrd.settings.registerTab({...}) at the end of init().
|
||||||
|
10. Test by reloading; check window.hyprcrd.plugins includes your meta entry.
|
||||||
|
11. Verify it can be disabled via settings → plugins (assets must vanish from page source).
|
||||||
|
12. If adding a new core hook is unavoidable, document it HERE before merging.
|
||||||
|
```
|
||||||
|
|
||||||
|
## API quick reference (copy-paste safe)
|
||||||
|
|
||||||
|
### Register a markdown fenced block
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.hyprcrd.markdown.registerFencedBlock('mylang', (content, { esc }) => {
|
||||||
|
return `<div class="my-block">${esc(content)}</div>`;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register an editor toolbar command
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.hyprcrd.editor.registerCommand('mycmd', () => {
|
||||||
|
const ed = window.hyprcrd.editor.api;
|
||||||
|
if (!ed) return; // editor not initialized yet (shouldn't happen on user click)
|
||||||
|
ed.insertMd('<<', '>>', 'placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a button with data-md="mycmd" to #md-toolbar via JS:
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.dataset.md = 'mycmd';
|
||||||
|
btn.className = 'toolbar-btn';
|
||||||
|
btn.innerHTML = '<i class="ph ph-star text-sm"></i>';
|
||||||
|
document.getElementById('md-toolbar').appendChild(btn);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register a settings tab
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.hyprcrd.settings.registerTab({
|
||||||
|
id: 'mything',
|
||||||
|
label: 'my thing',
|
||||||
|
icon: 'ph-star',
|
||||||
|
onShow: (panelEl) => {
|
||||||
|
panelEl.innerHTML = `<p>Settings UI here</p>`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open it programmatically:
|
||||||
|
window.hyprcrd.settings.openTab('mything');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register a backend API route (in backend.php)
|
||||||
|
|
||||||
|
```php
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
if ($route === '/api/myplugin/things' && $method === 'GET') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Make a CSRF-protected API call (frontend)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await window.hyprcrd.api('api/myplugin/things', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: 'foo' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encrypt credentials (in backend.php, reusing the vault key)
|
||||||
|
|
||||||
|
```php
|
||||||
|
function myEncKey(): string {
|
||||||
|
$db = Database::connect();
|
||||||
|
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||||
|
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||||
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||||
|
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||||
|
}
|
||||||
|
function myEncrypt(string $plain): string {
|
||||||
|
$key = myEncKey();
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
function myDecrypt(string $encoded): string {
|
||||||
|
$key = myEncKey();
|
||||||
|
$raw = base64_decode($encoded);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
return openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16)) ?: '';
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal pure-PHP IMAP4rev1 client.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - SSL/TLS connections (imaps://, port 993)
|
||||||
|
* - LOGIN, SELECT, UID SEARCH, UID FETCH, LOGOUT
|
||||||
|
* - MIME message body extraction (text/plain preferred, HTML fallback)
|
||||||
|
* - Quoted-printable + base64 decoding
|
||||||
|
* - MIME-encoded header decoding (=?utf-8?B?...?=)
|
||||||
|
*
|
||||||
|
* NOT supported (deferred for MVP): STARTTLS, IDLE, threads, attachments
|
||||||
|
* download, server-side search, multiple folders.
|
||||||
|
*
|
||||||
|
* Errors throw RuntimeException with a friendly message.
|
||||||
|
*/
|
||||||
|
class ImapClient
|
||||||
|
{
|
||||||
|
private $sock = null;
|
||||||
|
private int $tag = 0;
|
||||||
|
private string $host;
|
||||||
|
private int $port;
|
||||||
|
private string $encryption; // 'ssl' | 'tls' | 'none'
|
||||||
|
private int $timeout;
|
||||||
|
|
||||||
|
public function __construct(string $host, int $port = 993, string $encryption = 'ssl', int $timeout = 15)
|
||||||
|
{
|
||||||
|
$this->host = $host;
|
||||||
|
$this->port = $port;
|
||||||
|
$this->encryption = $encryption;
|
||||||
|
$this->timeout = $timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function connect(): void
|
||||||
|
{
|
||||||
|
$scheme = $this->encryption === 'ssl' ? 'ssl' : 'tcp';
|
||||||
|
$url = $scheme . '://' . $this->host . ':' . $this->port;
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => true,
|
||||||
|
'verify_peer_name' => true,
|
||||||
|
'allow_self_signed' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$errno = 0; $errstr = '';
|
||||||
|
$this->sock = @stream_socket_client($url, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT, $ctx);
|
||||||
|
if (!$this->sock) {
|
||||||
|
throw new RuntimeException("Connect failed: $errstr ($errno)");
|
||||||
|
}
|
||||||
|
stream_set_timeout($this->sock, $this->timeout);
|
||||||
|
// Read greeting
|
||||||
|
$greet = $this->readLine();
|
||||||
|
if (strpos($greet, '* OK') !== 0 && strpos($greet, '* PREAUTH') !== 0) {
|
||||||
|
throw new RuntimeException('Bad server greeting: ' . trim($greet));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $user, string $pass): void
|
||||||
|
{
|
||||||
|
// Quote string literals to be safe
|
||||||
|
$user = $this->quoteString($user);
|
||||||
|
$pass = $this->quoteString($pass);
|
||||||
|
$resp = $this->command("LOGIN $user $pass");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('LOGIN failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectFolder(string $folder = 'INBOX'): array
|
||||||
|
{
|
||||||
|
$folder = $this->quoteString($folder);
|
||||||
|
$resp = $this->command("SELECT $folder");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('SELECT failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
// Parse EXISTS count from untagged responses
|
||||||
|
$exists = 0;
|
||||||
|
foreach ($resp['untagged'] as $line) {
|
||||||
|
if (preg_match('/^\* (\d+) EXISTS/', $line, $m)) {
|
||||||
|
$exists = (int)$m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['exists' => $exists];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search returns UIDs of all messages matching criteria. */
|
||||||
|
public function uidSearch(string $criteria = 'ALL'): array
|
||||||
|
{
|
||||||
|
$resp = $this->command("UID SEARCH $criteria");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('SEARCH failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
$uids = [];
|
||||||
|
foreach ($resp['untagged'] as $line) {
|
||||||
|
if (preg_match('/^\* SEARCH(.*)$/i', $line, $m)) {
|
||||||
|
preg_match_all('/\d+/', $m[1], $nums);
|
||||||
|
foreach ($nums[0] as $n) $uids[] = (int)$n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $uids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch envelope-like headers + spam-relevant fields for a list of UIDs.
|
||||||
|
* Returns [{uid, subject, from, date, seen, headers: <full-parsed-map>}, ...]
|
||||||
|
* The `headers` map contains lowercased keys ready for SpamDetector.
|
||||||
|
*/
|
||||||
|
public function fetchHeaders(array $uids): array
|
||||||
|
{
|
||||||
|
if (!$uids) return [];
|
||||||
|
sort($uids);
|
||||||
|
$set = $this->compactUidSet($uids);
|
||||||
|
// Pull more fields so SpamDetector has signals to work with
|
||||||
|
$fields = 'FROM SUBJECT DATE REPLY-TO MESSAGE-ID '
|
||||||
|
. 'X-SPAM-FLAG X-SPAM-STATUS X-SPAM-SCORE '
|
||||||
|
. 'AUTHENTICATION-RESULTS RECEIVED-SPF '
|
||||||
|
. 'LIST-UNSUBSCRIBE';
|
||||||
|
$resp = $this->command("UID FETCH $set (UID FLAGS BODY[HEADER.FIELDS ($fields)])");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('FETCH headers failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
$messages = [];
|
||||||
|
foreach ($resp['fetches'] as $entry) {
|
||||||
|
$msg = [
|
||||||
|
'uid' => null,
|
||||||
|
'subject' => '',
|
||||||
|
'from' => '',
|
||||||
|
'date' => '',
|
||||||
|
'seen' => false,
|
||||||
|
'headers' => [],
|
||||||
|
];
|
||||||
|
if (preg_match('/UID (\d+)/', $entry['preamble'], $m)) $msg['uid'] = (int)$m[1];
|
||||||
|
if (preg_match('/FLAGS \(([^)]*)\)/', $entry['preamble'], $m)) {
|
||||||
|
$msg['seen'] = stripos($m[1], '\\Seen') !== false;
|
||||||
|
}
|
||||||
|
$headers = $this->parseHeaders($entry['body']);
|
||||||
|
// Decode MIME-encoded fields used by the UI
|
||||||
|
$msg['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
|
||||||
|
$msg['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
|
||||||
|
$msg['date'] = $headers['date'] ?? '';
|
||||||
|
// Pass the decoded versions back into the header map so SpamDetector
|
||||||
|
// sees the same strings the user sees
|
||||||
|
$headers['subject'] = $msg['subject'];
|
||||||
|
$headers['from'] = $msg['from'];
|
||||||
|
if (!empty($headers['reply-to'])) {
|
||||||
|
$headers['reply-to'] = $this->decodeMimeHeader($headers['reply-to']);
|
||||||
|
}
|
||||||
|
$msg['headers'] = $headers;
|
||||||
|
if ($msg['uid']) $messages[] = $msg;
|
||||||
|
}
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full message body and parse it.
|
||||||
|
* Returns ['headers' => [...], 'text' => '...', 'html' => '...', 'attachments' => [...]]
|
||||||
|
*/
|
||||||
|
public function fetchMessage(int $uid): array
|
||||||
|
{
|
||||||
|
$resp = $this->command("UID FETCH $uid (UID BODY.PEEK[])");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('FETCH body failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
if (!$resp['fetches']) {
|
||||||
|
throw new RuntimeException('Message not found');
|
||||||
|
}
|
||||||
|
$raw = $resp['fetches'][0]['body'];
|
||||||
|
return $this->parseMessage($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a list of UIDs as deleted (\Deleted flag) and expunge them.
|
||||||
|
* After expunge, those messages are gone from the server permanently.
|
||||||
|
* Returns the number of UIDs that were processed.
|
||||||
|
*/
|
||||||
|
public function deleteUids(array $uids): int
|
||||||
|
{
|
||||||
|
if (!$uids) return 0;
|
||||||
|
$set = $this->compactUidSet($uids);
|
||||||
|
$resp = $this->command("UID STORE $set +FLAGS (\\Deleted)");
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('STORE \\Deleted failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
$resp = $this->command('EXPUNGE');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
throw new RuntimeException('EXPUNGE failed: ' . $resp['text']);
|
||||||
|
}
|
||||||
|
return count($uids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
if ($this->sock) {
|
||||||
|
try { @$this->command('LOGOUT'); } catch (\Throwable $e) {}
|
||||||
|
@fclose($this->sock);
|
||||||
|
$this->sock = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __destruct()
|
||||||
|
{
|
||||||
|
$this->logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Low-level protocol ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private function nextTag(): string
|
||||||
|
{
|
||||||
|
$this->tag++;
|
||||||
|
return 'A' . str_pad((string)$this->tag, 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeLine(string $line): void
|
||||||
|
{
|
||||||
|
if (fwrite($this->sock, $line . "\r\n") === false) {
|
||||||
|
throw new RuntimeException('Socket write failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readLine(): string
|
||||||
|
{
|
||||||
|
$line = fgets($this->sock);
|
||||||
|
if ($line === false) {
|
||||||
|
throw new RuntimeException('Socket read failed (timeout?)');
|
||||||
|
}
|
||||||
|
return $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readBytes(int $n): string
|
||||||
|
{
|
||||||
|
$buf = '';
|
||||||
|
while (strlen($buf) < $n) {
|
||||||
|
$chunk = fread($this->sock, $n - strlen($buf));
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
throw new RuntimeException('Socket read truncated');
|
||||||
|
}
|
||||||
|
$buf .= $chunk;
|
||||||
|
}
|
||||||
|
return $buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a command and read all response lines until tagged completion.
|
||||||
|
* Handles literal continuations ({N}\r\n followed by N bytes).
|
||||||
|
* Returns ['ok' => bool, 'text' => string, 'untagged' => [...], 'fetches' => [...]]
|
||||||
|
*/
|
||||||
|
private function command(string $cmd): array
|
||||||
|
{
|
||||||
|
$tag = $this->nextTag();
|
||||||
|
$this->writeLine("$tag $cmd");
|
||||||
|
|
||||||
|
$untagged = [];
|
||||||
|
$fetches = []; // [{preamble, body}]
|
||||||
|
$okResult = null;
|
||||||
|
$okText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$line = $this->readLine();
|
||||||
|
// Detect literal: {N}\r\n at end → read N more bytes, then continue
|
||||||
|
$literalBuf = '';
|
||||||
|
while (preg_match('/\{(\d+)\}\r?\n$/', $line, $m)) {
|
||||||
|
$bytes = (int)$m[1];
|
||||||
|
$literalBuf .= $this->readBytes($bytes);
|
||||||
|
$next = $this->readLine();
|
||||||
|
$line = rtrim($line, "\r\n") . "\x00LIT\x00" . $literalBuf . "\x00LITEND\x00" . $next;
|
||||||
|
$literalBuf = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagged response check
|
||||||
|
if (strpos($line, $tag . ' ') === 0) {
|
||||||
|
$rest = substr($line, strlen($tag) + 1);
|
||||||
|
$okResult = (strpos($rest, 'OK') === 0);
|
||||||
|
$okText = trim($rest);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untagged
|
||||||
|
if (strpos($line, '* ') === 0) {
|
||||||
|
$untagged[] = trim($line);
|
||||||
|
// Detect FETCH responses with literal body
|
||||||
|
if (preg_match('/^\* \d+ FETCH /', $line)) {
|
||||||
|
if (preg_match('/\x00LIT\x00(.*?)\x00LITEND\x00/s', $line, $m)) {
|
||||||
|
$body = $m[1];
|
||||||
|
$preamble = preg_replace('/\x00LIT\x00.*?\x00LITEND\x00.*$/s', '', $line);
|
||||||
|
$fetches[] = ['preamble' => $preamble, 'body' => $body];
|
||||||
|
} else {
|
||||||
|
// No literal, body might be inline (small messages)
|
||||||
|
$fetches[] = ['preamble' => $line, 'body' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => $okResult, 'text' => $okText, 'untagged' => $untagged, 'fetches' => $fetches];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quoteString(string $s): string
|
||||||
|
{
|
||||||
|
// Simple quoting; passwords with quotes are escaped
|
||||||
|
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $s) . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compactUidSet(array $uids): string
|
||||||
|
{
|
||||||
|
// For simplicity, just send a comma list (most servers accept this).
|
||||||
|
// Could be optimized to ranges (1:5,10,15:20).
|
||||||
|
return implode(',', $uids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MIME parsing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function parseHeaders(string $raw): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
$lines = preg_split('/\r?\n/', $raw);
|
||||||
|
$current = null;
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if ($line === '') break;
|
||||||
|
if (preg_match('/^([!-9;-~]+):\s*(.*)$/', $line, $m)) {
|
||||||
|
$current = strtolower($m[1]);
|
||||||
|
$headers[$current] = $m[2];
|
||||||
|
} elseif ($current !== null && preg_match('/^[ \t]/', $line)) {
|
||||||
|
// Continuation
|
||||||
|
$headers[$current] .= ' ' . trim($line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decodeMimeHeader(string $value): string
|
||||||
|
{
|
||||||
|
// Decode =?charset?B?...?= or =?charset?Q?...?=
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/',
|
||||||
|
function ($m) {
|
||||||
|
$charset = strtoupper($m[1]);
|
||||||
|
$enc = strtoupper($m[2]);
|
||||||
|
$data = $m[3];
|
||||||
|
if ($enc === 'B') {
|
||||||
|
$decoded = base64_decode($data);
|
||||||
|
} else { // Q
|
||||||
|
$decoded = quoted_printable_decode(str_replace('_', ' ', $data));
|
||||||
|
}
|
||||||
|
if ($charset !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||||
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||||
|
}
|
||||||
|
return $decoded;
|
||||||
|
},
|
||||||
|
$value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a full RFC 822 message into headers + plain/html bodies.
|
||||||
|
* Returns ['headers' => [...], 'text' => string, 'html' => string]
|
||||||
|
*/
|
||||||
|
public function parseMessage(string $raw): array
|
||||||
|
{
|
||||||
|
// Split header / body at first blank line
|
||||||
|
$pos = strpos($raw, "\r\n\r\n");
|
||||||
|
if ($pos === false) $pos = strpos($raw, "\n\n");
|
||||||
|
$headerRaw = $pos !== false ? substr($raw, 0, $pos) : $raw;
|
||||||
|
$bodyRaw = $pos !== false ? substr($raw, $pos + ($raw[$pos] === "\r" ? 4 : 2)) : '';
|
||||||
|
|
||||||
|
$headers = $this->parseHeaders($headerRaw);
|
||||||
|
$headers['subject'] = $this->decodeMimeHeader($headers['subject'] ?? '');
|
||||||
|
$headers['from'] = $this->decodeMimeHeader($headers['from'] ?? '');
|
||||||
|
$headers['to'] = $this->decodeMimeHeader($headers['to'] ?? '');
|
||||||
|
|
||||||
|
$contentType = $headers['content-type'] ?? 'text/plain';
|
||||||
|
$encoding = strtolower($headers['content-transfer-encoding'] ?? '7bit');
|
||||||
|
|
||||||
|
$text = '';
|
||||||
|
$html = '';
|
||||||
|
|
||||||
|
if (stripos($contentType, 'multipart/') === 0) {
|
||||||
|
$boundary = $this->extractBoundary($contentType);
|
||||||
|
if ($boundary) {
|
||||||
|
[$text, $html] = $this->parseMultipart($bodyRaw, $boundary);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$decoded = $this->decodeBody($bodyRaw, $encoding);
|
||||||
|
$charset = $this->extractCharset($contentType);
|
||||||
|
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||||
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||||
|
}
|
||||||
|
if (stripos($contentType, 'text/html') !== false) {
|
||||||
|
$html = $decoded;
|
||||||
|
} else {
|
||||||
|
$text = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['headers' => $headers, 'text' => $text, 'html' => $html];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseMultipart(string $body, string $boundary): array
|
||||||
|
{
|
||||||
|
$parts = explode('--' . $boundary, $body);
|
||||||
|
// First chunk before first boundary, last chunk has -- closing — discard
|
||||||
|
array_shift($parts);
|
||||||
|
array_pop($parts);
|
||||||
|
|
||||||
|
$text = '';
|
||||||
|
$html = '';
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$part = ltrim($part, "\r\n");
|
||||||
|
$pos = strpos($part, "\r\n\r\n");
|
||||||
|
if ($pos === false) $pos = strpos($part, "\n\n");
|
||||||
|
if ($pos === false) continue;
|
||||||
|
$partHeaderRaw = substr($part, 0, $pos);
|
||||||
|
$partBody = substr($part, $pos + ($part[$pos] === "\r" ? 4 : 2));
|
||||||
|
$partHeaders = $this->parseHeaders($partHeaderRaw);
|
||||||
|
$ct = $partHeaders['content-type'] ?? 'text/plain';
|
||||||
|
$cte = strtolower($partHeaders['content-transfer-encoding'] ?? '7bit');
|
||||||
|
|
||||||
|
if (stripos($ct, 'multipart/') === 0) {
|
||||||
|
$innerBoundary = $this->extractBoundary($ct);
|
||||||
|
if ($innerBoundary) {
|
||||||
|
[$nestedText, $nestedHtml] = $this->parseMultipart($partBody, $innerBoundary);
|
||||||
|
if ($nestedText && !$text) $text = $nestedText;
|
||||||
|
if ($nestedHtml && !$html) $html = $nestedHtml;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = $this->decodeBody($partBody, $cte);
|
||||||
|
$charset = $this->extractCharset($ct);
|
||||||
|
if ($charset && strtoupper($charset) !== 'UTF-8' && function_exists('mb_convert_encoding')) {
|
||||||
|
$decoded = @mb_convert_encoding($decoded, 'UTF-8', $charset) ?: $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripos($ct, 'text/plain') !== false && !$text) {
|
||||||
|
$text = $decoded;
|
||||||
|
} elseif (stripos($ct, 'text/html') !== false && !$html) {
|
||||||
|
$html = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$text, $html];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeBody(string $body, string $encoding): string
|
||||||
|
{
|
||||||
|
switch ($encoding) {
|
||||||
|
case 'base64':
|
||||||
|
return base64_decode(preg_replace('/\s+/', '', $body)) ?: '';
|
||||||
|
case 'quoted-printable':
|
||||||
|
return quoted_printable_decode($body);
|
||||||
|
default:
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractBoundary(string $contentType): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/boundary\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractCharset(string $contentType): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/charset\s*=\s*"?([^";\s]+)"?/i', $contentType, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpamDetector — multi-signal heuristic spam scoring.
|
||||||
|
*
|
||||||
|
* Composes signals from:
|
||||||
|
* - Upstream filter results (X-Spam-Flag, X-Spam-Status, X-Spam-Score)
|
||||||
|
* - Authentication-Results / Received-SPF (SPF/DKIM/DMARC pass/fail)
|
||||||
|
* - Subject heuristics (ALL CAPS, excess punctuation, spam keywords EN+DE)
|
||||||
|
* - Sender heuristics (suspicious TLDs, digit-heavy domains, lookalikes,
|
||||||
|
* display name mismatch, missing/mismatched Reply-To)
|
||||||
|
* - Optional body heuristics (link density, short URLs, currency spam)
|
||||||
|
*
|
||||||
|
* Scoring:
|
||||||
|
* 0–3 → clean (no marking)
|
||||||
|
* 4–7 → suspicious (yellow)
|
||||||
|
* 8+ → spam (red)
|
||||||
|
*/
|
||||||
|
class SpamDetector
|
||||||
|
{
|
||||||
|
/** User-defined block rules: sender emails (lowercase). */
|
||||||
|
private array $blockEmails = [];
|
||||||
|
/** User-defined block rules: sender domains (lowercase). */
|
||||||
|
private array $blockDomains = [];
|
||||||
|
/** User-defined allow rules. */
|
||||||
|
private array $allowEmails = [];
|
||||||
|
private array $allowDomains = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $rules Optional array of rule rows from email_spam_rules:
|
||||||
|
* [{rule_type, sender_email, sender_domain}, ...]
|
||||||
|
*/
|
||||||
|
public function __construct(array $rules = [])
|
||||||
|
{
|
||||||
|
foreach ($rules as $r) {
|
||||||
|
$type = $r['rule_type'] ?? '';
|
||||||
|
$email = strtolower(trim((string)($r['sender_email'] ?? '')));
|
||||||
|
$domain = strtolower(trim((string)($r['sender_domain'] ?? '')));
|
||||||
|
if ($type === 'block') {
|
||||||
|
if ($email) $this->blockEmails[] = $email;
|
||||||
|
if ($domain) $this->blockDomains[] = $domain;
|
||||||
|
} elseif ($type === 'allow') {
|
||||||
|
if ($email) $this->allowEmails[] = $email;
|
||||||
|
if ($domain) $this->allowDomains[] = $domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** English + German spam keywords. Each match adds 2 points (capped). */
|
||||||
|
private const SPAM_PHRASES = [
|
||||||
|
// English
|
||||||
|
'viagra', 'cialis', 'pharmacy', 'rolex', 'casino', 'lottery', 'winner',
|
||||||
|
'congratulations', 'claim your prize', 'act now', 'click here', 'free gift',
|
||||||
|
'limited time', 'risk free', 'no obligation', 'satisfaction guaranteed',
|
||||||
|
'money back', 'lose weight', 'work from home', 'make money fast',
|
||||||
|
'earn extra cash', 'inheritance', 'beneficiary', 'next of kin',
|
||||||
|
'crypto millionaire', '100% free', '100% satisfied', 'urgent reply',
|
||||||
|
'wire transfer', 'western union', 'nigerian prince', 'unclaimed funds',
|
||||||
|
'verify your account', 'suspended account', 'confirm your identity',
|
||||||
|
// German
|
||||||
|
'sie haben gewonnen', 'gewinnspiel', 'lotto', 'erbschaft', 'sofort gewinn',
|
||||||
|
'kostenlos', 'gratis', 'nur heute', 'einmalig', 'jetzt zugreifen',
|
||||||
|
'jetzt bestellen', 'risikofrei', 'geld verdienen', 'schnell reich',
|
||||||
|
'abnehmen ohne', 'wundermittel', 'klicken sie hier', 'bestätigen sie',
|
||||||
|
'ihr konto wurde', 'ihr passwort', 'verifizieren sie', 'dringend antworten',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Suspicious TLDs frequently used by spammers. */
|
||||||
|
private const SUSPICIOUS_TLDS = [
|
||||||
|
'top', 'xyz', 'click', 'link', 'work', 'gq', 'ml', 'tk', 'ga', 'cf',
|
||||||
|
'loan', 'racing', 'win', 'download', 'review', 'country', 'kim',
|
||||||
|
'science', 'party', 'bid', 'trade', 'date', 'stream', 'cricket',
|
||||||
|
'webcam', 'mom', 'rest',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** URL shorteners (high spam correlation in cold email). */
|
||||||
|
private const SHORT_URL_HOSTS = [
|
||||||
|
'bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly', 'is.gd', 'buff.ly',
|
||||||
|
'tiny.cc', 'cutt.ly', 'shorturl.at', 'rebrand.ly', 'rb.gy',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $headers Lowercase-keyed header map (as returned by ImapClient::parseHeaders)
|
||||||
|
* @param string|null $bodyText Optional plaintext body for content checks
|
||||||
|
* @return array{score:int, level:string, reasons:array<string>}
|
||||||
|
*/
|
||||||
|
public function score(array $headers, ?string $bodyText = null): array
|
||||||
|
{
|
||||||
|
$score = 0;
|
||||||
|
$reasons = [];
|
||||||
|
|
||||||
|
// ── User-defined rules (override the heuristic with strong weight) ──
|
||||||
|
$fromAddrEarly = $this->extractAddress($headers['from'] ?? '');
|
||||||
|
$fromDomainEarly = strtolower($this->extractDomain($fromAddrEarly));
|
||||||
|
$fromAddrLow = strtolower($fromAddrEarly);
|
||||||
|
|
||||||
|
if ($fromAddrLow && in_array($fromAddrLow, $this->allowEmails, true)) {
|
||||||
|
$score -= 15;
|
||||||
|
$reasons[] = 'sender on allowlist (email)';
|
||||||
|
} elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->allowDomains, true)) {
|
||||||
|
$score -= 12;
|
||||||
|
$reasons[] = 'sender domain on allowlist';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fromAddrLow && in_array($fromAddrLow, $this->blockEmails, true)) {
|
||||||
|
$score += 15;
|
||||||
|
$reasons[] = 'sender on blocklist (email)';
|
||||||
|
} elseif ($fromDomainEarly && in_array($fromDomainEarly, $this->blockDomains, true)) {
|
||||||
|
$score += 12;
|
||||||
|
$reasons[] = 'sender domain on blocklist';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upstream filter verdict (strongest signal) ──────────────
|
||||||
|
|
||||||
|
$xSpamFlag = strtolower(trim($headers['x-spam-flag'] ?? ''));
|
||||||
|
if ($xSpamFlag === 'yes') {
|
||||||
|
$score += 10;
|
||||||
|
$reasons[] = 'upstream X-Spam-Flag: YES';
|
||||||
|
}
|
||||||
|
|
||||||
|
$xSpamStatus = $headers['x-spam-status'] ?? '';
|
||||||
|
if (preg_match('/^Yes/i', $xSpamStatus)) {
|
||||||
|
$score += 8;
|
||||||
|
$reasons[] = 'upstream X-Spam-Status: Yes';
|
||||||
|
}
|
||||||
|
if (preg_match('/score\s*=\s*([\d.]+)/i', $xSpamStatus, $m)) {
|
||||||
|
$sa = (float)$m[1];
|
||||||
|
if ($sa >= 5) {
|
||||||
|
$score += 5;
|
||||||
|
$reasons[] = "SpamAssassin score $sa";
|
||||||
|
} elseif ($sa >= 3) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = "SpamAssassin score $sa";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$xSpamScore = $headers['x-spam-score'] ?? '';
|
||||||
|
if (is_numeric(trim($xSpamScore)) && (float)$xSpamScore >= 5) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = "X-Spam-Score $xSpamScore";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authentication results (SPF/DKIM/DMARC) ─────────────────
|
||||||
|
|
||||||
|
$authResults = strtolower($headers['authentication-results'] ?? '');
|
||||||
|
$receivedSpf = strtolower($headers['received-spf'] ?? '');
|
||||||
|
|
||||||
|
if (str_contains($authResults, 'spf=fail') || str_starts_with($receivedSpf, 'fail')) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = 'SPF fail';
|
||||||
|
} elseif (str_contains($authResults, 'spf=softfail') || str_starts_with($receivedSpf, 'softfail')) {
|
||||||
|
$score += 1;
|
||||||
|
$reasons[] = 'SPF softfail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($authResults, 'dkim=fail')) {
|
||||||
|
$score += 3;
|
||||||
|
$reasons[] = 'DKIM fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($authResults, 'dmarc=fail')) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = 'DMARC fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing Message-ID is rare in legitimate mail
|
||||||
|
if (empty($headers['message-id']) && empty($headers['x-spam-flag'])) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = 'missing Message-ID';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subject heuristics ──────────────────────────────────────
|
||||||
|
|
||||||
|
$subject = $headers['subject'] ?? '';
|
||||||
|
if ($subject !== '') {
|
||||||
|
// ALL CAPS check (only count letters; ignore short subjects)
|
||||||
|
$letters = preg_replace('/[^A-Za-zÄÖÜäöüß]/u', '', $subject);
|
||||||
|
if (strlen($letters) >= 12) {
|
||||||
|
$upper = preg_replace('/[^A-ZÄÖÜ]/u', '', $letters);
|
||||||
|
$ratio = strlen($upper) / max(1, strlen($letters));
|
||||||
|
if ($ratio > 0.7) {
|
||||||
|
$score += 3;
|
||||||
|
$reasons[] = 'ALL CAPS subject';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excessive exclamation/question marks
|
||||||
|
$excessPunct = preg_match_all('/[!?]{2,}/', $subject);
|
||||||
|
if ($excessPunct >= 1) {
|
||||||
|
$score += min(3, $excessPunct + 1);
|
||||||
|
$reasons[] = 'excessive punctuation';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency clusters
|
||||||
|
if (preg_match_all('/[\$€£¥]\s*\d+|\d+\s*[\$€£¥]|EUR\s*\d+|USD\s*\d+/i', $subject, $cm)) {
|
||||||
|
if (count($cm[0]) >= 1) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = 'currency in subject';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword matches (capped at +6)
|
||||||
|
$kwScore = 0;
|
||||||
|
$kwHits = 0;
|
||||||
|
$lowSubject = function_exists('mb_strtolower') ? mb_strtolower($subject) : strtolower($subject);
|
||||||
|
foreach (self::SPAM_PHRASES as $phrase) {
|
||||||
|
if (str_contains($lowSubject, $phrase)) {
|
||||||
|
$kwScore += 2;
|
||||||
|
$kwHits++;
|
||||||
|
if ($kwHits >= 3) break; // cap at 3 distinct hits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($kwScore > 0) {
|
||||||
|
$score += min(6, $kwScore);
|
||||||
|
$reasons[] = "$kwHits spam keyword" . ($kwHits === 1 ? '' : 's');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cyrillic homoglyph attack: Latin recipients getting Cyrillic letters
|
||||||
|
if (preg_match('/[\x{0400}-\x{04FF}]/u', $subject)
|
||||||
|
&& preg_match('/[a-zA-Z]/', $subject)) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = 'mixed Latin/Cyrillic (homoglyph)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sender heuristics ───────────────────────────────────────
|
||||||
|
|
||||||
|
$from = $headers['from'] ?? '';
|
||||||
|
$replyTo = $headers['reply-to'] ?? '';
|
||||||
|
|
||||||
|
$fromAddr = $this->extractAddress($from);
|
||||||
|
$replyAddr = $this->extractAddress($replyTo);
|
||||||
|
$fromDomain = $this->extractDomain($fromAddr);
|
||||||
|
$replyDomain = $this->extractDomain($replyAddr);
|
||||||
|
|
||||||
|
// Reply-To pointing to a different domain than From (classic phishing)
|
||||||
|
if ($replyDomain && $fromDomain && strcasecmp($replyDomain, $fromDomain) !== 0) {
|
||||||
|
$score += 3;
|
||||||
|
$reasons[] = "Reply-To domain mismatch ($replyDomain ≠ $fromDomain)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspicious TLD
|
||||||
|
if ($fromDomain) {
|
||||||
|
$tld = strtolower((string)substr(strrchr($fromDomain, '.'), 1));
|
||||||
|
if (in_array($tld, self::SUSPICIOUS_TLDS, true)) {
|
||||||
|
$score += 3;
|
||||||
|
$reasons[] = "suspicious TLD .$tld";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digit-heavy domain (often throwaways like x4892jk-shop.com)
|
||||||
|
$digitCount = preg_match_all('/\d/', $fromDomain);
|
||||||
|
$domainLen = strlen($fromDomain);
|
||||||
|
if ($domainLen > 8 && $digitCount / $domainLen > 0.3) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = 'digit-heavy sender domain';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyphen overload
|
||||||
|
if (substr_count($fromDomain, '-') >= 3) {
|
||||||
|
$score += 1;
|
||||||
|
$reasons[] = 'many hyphens in domain';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display name impersonates an email address that doesn't match the actual address
|
||||||
|
if (preg_match('/^"?([^"<]+)"?\s*<([^>]+)>$/', $from, $fm)) {
|
||||||
|
$displayName = trim($fm[1]);
|
||||||
|
$actualAddr = trim($fm[2]);
|
||||||
|
if (preg_match('/[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $displayName, $dm)) {
|
||||||
|
if (strcasecmp($dm[0], $actualAddr) !== 0) {
|
||||||
|
$score += 4;
|
||||||
|
$reasons[] = 'display name impersonates a different email';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List-Unsubscribe present is a slight signal it's bulk
|
||||||
|
// (not necessarily spam, just a small positive bump)
|
||||||
|
if (!empty($headers['list-unsubscribe'])) {
|
||||||
|
$score += 1;
|
||||||
|
$reasons[] = 'bulk mail (list-unsubscribe)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body heuristics (only if body is provided) ──────────────
|
||||||
|
|
||||||
|
if ($bodyText) {
|
||||||
|
// Count URLs
|
||||||
|
preg_match_all('#https?://([^\s<>"\']+)#i', $bodyText, $urlMatches);
|
||||||
|
$urls = $urlMatches[1] ?? [];
|
||||||
|
$urlCount = count($urls);
|
||||||
|
if ($urlCount > 8) {
|
||||||
|
$score += min(4, intdiv($urlCount, 4));
|
||||||
|
$reasons[] = "$urlCount links in body";
|
||||||
|
}
|
||||||
|
|
||||||
|
$shortCount = 0;
|
||||||
|
foreach ($urls as $u) {
|
||||||
|
$host = strtolower(parse_url('http://' . $u, PHP_URL_HOST) ?? '');
|
||||||
|
if ($host && in_array($host, self::SHORT_URL_HOSTS, true)) $shortCount++;
|
||||||
|
}
|
||||||
|
if ($shortCount >= 2) {
|
||||||
|
$score += 2;
|
||||||
|
$reasons[] = "$shortCount short URLs";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Determine level ─────────────────────────────────────────
|
||||||
|
|
||||||
|
if ($score >= 8) {
|
||||||
|
$level = 'spam';
|
||||||
|
} elseif ($score >= 4) {
|
||||||
|
$level = 'suspicious';
|
||||||
|
} else {
|
||||||
|
$level = 'clean';
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['score' => $score, 'level' => $level, 'reasons' => $reasons];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractAddress(string $field): string
|
||||||
|
{
|
||||||
|
if (preg_match('/<([^>]+)>/', $field, $m)) return trim($m[1]);
|
||||||
|
if (preg_match('/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $field, $m)) return $m[0];
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractDomain(string $address): string
|
||||||
|
{
|
||||||
|
$at = strrpos($address, '@');
|
||||||
|
return $at === false ? '' : strtolower(substr($address, $at + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# email plugin — deferred features
|
||||||
|
|
||||||
|
> **Note:** Account management lives in the main settings overlay under
|
||||||
|
> "mail" (added via `hyprcrd.settings.registerTab`). The email overlay
|
||||||
|
> shows a welcome screen with a "configure accounts" button when no
|
||||||
|
> accounts are set up — that button switches to the settings tab.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
This is the MVP scope. The following are intentionally not implemented yet
|
||||||
|
and can be added later as plugin-only changes (no core touch needed):
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
- [ ] **SMTP / sending** — currently read-only. Add SmtpClient.php + a
|
||||||
|
compose view in the overlay. Reuse account credentials (most providers
|
||||||
|
use the same login for IMAP + SMTP, just different host/port).
|
||||||
|
- [ ] **STARTTLS on port 143** — only direct SSL on 993 works today. Need to
|
||||||
|
send `STLS` command after the greeting and wrap the socket via
|
||||||
|
`stream_socket_enable_crypto`.
|
||||||
|
- [ ] **IDLE / push** — currently each `loadMessages()` is a fresh poll. IDLE
|
||||||
|
would let the server notify on new mail. Tricky with PHP request model;
|
||||||
|
probably needs a small daemon or long-poll endpoint.
|
||||||
|
- [ ] **OAuth2 (XOAUTH2)** — Gmail/O365 require OAuth instead of app
|
||||||
|
passwords for full functionality. Big chunk of work: token exchange,
|
||||||
|
refresh, storage.
|
||||||
|
|
||||||
|
## Folders & search
|
||||||
|
|
||||||
|
- [ ] **Multiple folders** — schema has `folder` column but UI exposes only
|
||||||
|
one. Add folder list pane (like Thunderbird's left sidebar) using
|
||||||
|
`LIST "" *`.
|
||||||
|
- [ ] **Server-side search** — `UID SEARCH FROM "..."`, `SUBJECT "..."`,
|
||||||
|
`SINCE date`, etc. Currently the UI shows the latest 30 only.
|
||||||
|
- [ ] **Threading** — group messages by `In-Reply-To` / `References` headers.
|
||||||
|
- [ ] **Mark as read / unread** — `UID STORE +FLAGS (\Seen)`.
|
||||||
|
- [ ] **Move to folder** — `UID COPY <folder>` + delete from current.
|
||||||
|
- [x] **Delete on server** — implemented: multi-select checkboxes in list pane,
|
||||||
|
"delete" button in selection bar, calls `UID STORE +FLAGS (\Deleted)`
|
||||||
|
+ `EXPUNGE` (see `ImapClient::deleteUids()`).
|
||||||
|
|
||||||
|
## Body & attachments
|
||||||
|
|
||||||
|
- [x] **HTML email rendering** — implemented: opt-in "view as html" button per
|
||||||
|
message renders the HTML in a sandboxed iframe (srcdoc + DOMPurify).
|
||||||
|
External images are blocked by default; "load images (N)" button reveals
|
||||||
|
them on demand. CSS background-image url() declarations are also stripped
|
||||||
|
from inline styles. cid: refs aren't loaded (would need a separate route).
|
||||||
|
- [ ] **Inline images** — `cid:` references inside HTML need to fetch
|
||||||
|
`multipart/related` parts and serve them via a data: URL.
|
||||||
|
- [ ] **Attachments list + download** — parse `Content-Disposition: attachment`
|
||||||
|
parts, expose them in the message header bar, route through a backend
|
||||||
|
endpoint that streams the decoded part to the browser.
|
||||||
|
|
||||||
|
## UI / UX
|
||||||
|
|
||||||
|
- [ ] **Pagination** — currently hard limit of 30 newest messages.
|
||||||
|
- [ ] **Per-account folder + signature settings** — extend the form.
|
||||||
|
- [ ] **Notification on new mail** — needs IDLE or polling daemon.
|
||||||
|
- [ ] **Drag-extract** — drag a message onto the board to create a card at
|
||||||
|
that position.
|
||||||
|
- [ ] **Reply / forward** — needs SMTP first.
|
||||||
|
- [ ] **Address book / autocomplete** — pull historic sender addresses.
|
||||||
|
|
||||||
|
## Spam detection (current state)
|
||||||
|
|
||||||
|
- [x] **Multi-signal heuristic scorer** (`SpamDetector.php`):
|
||||||
|
header signals (X-Spam-*, SPF/DKIM/DMARC), subject heuristics
|
||||||
|
(CAPS, punctuation, EN+DE keyword phrases, Cyrillic homoglyphs),
|
||||||
|
sender heuristics (suspicious TLDs, digit-heavy domains, Reply-To
|
||||||
|
mismatch, display-name impersonation), bulk-mail signal.
|
||||||
|
Three levels: clean / suspicious / spam.
|
||||||
|
- [x] **Visual marking** in message list: red row + badge for spam,
|
||||||
|
yellow row + badge for suspicious. Hover tooltip shows reasons.
|
||||||
|
- [ ] **Body-content scoring** — `SpamDetector::score()` already accepts
|
||||||
|
a body parameter; wire it up in the message-open route to refine
|
||||||
|
the score with link density / short URL detection. Currently the
|
||||||
|
list view scores headers only.
|
||||||
|
- [x] **User feedback loop** — implemented: `email_spam_rules` table holds
|
||||||
|
block/allow rules per sender email or domain. UI has "select spam"
|
||||||
|
bulk-select button + "spam" / "not spam" buttons in the selection bar.
|
||||||
|
Free email providers (gmail, gmx, etc.) get email-only rules to avoid
|
||||||
|
blocking entire providers; other domains get domain-level rules.
|
||||||
|
Block adds +15, allow subtracts -15 in `SpamDetector`.
|
||||||
|
- [ ] **Bayesian classifier** — collect tokens from user-marked spam vs
|
||||||
|
ham; train a per-account model. SQLite-backed, no external deps.
|
||||||
|
- [ ] **Spam rules management UI** — currently rules are write-only via
|
||||||
|
mark buttons. A list view in settings would let users see and remove
|
||||||
|
stale rules.
|
||||||
|
|
||||||
|
## Hardening
|
||||||
|
|
||||||
|
- [ ] **Rate limiting** on the test endpoint to prevent credential brute force.
|
||||||
|
- [ ] **Better error messages** — currently raw IMAP server errors leak
|
||||||
|
through. Should map common ones (auth failed, connection refused,
|
||||||
|
cert error) to friendly text.
|
||||||
|
- [ ] **Cert pinning option** for users on self-hosted mail servers.
|
||||||
|
- [ ] **Connection reuse** — every API call opens + closes IMAP. A short
|
||||||
|
session cache (per account, 30s) would be much faster.
|
||||||
|
|
||||||
|
## Why these were deferred
|
||||||
|
|
||||||
|
The scope of "build an email plugin" is essentially "build a mail client",
|
||||||
|
which is a multi-month project on its own. The MVP proves the plugin system
|
||||||
|
can host a non-trivial feature with a backend, schema, encrypted
|
||||||
|
credentials, custom API routes, and a complex UI — all without touching
|
||||||
|
the hyprcrd core. Each item above can be added as an isolated patch to this
|
||||||
|
plugin directory, and the plugin can still be disabled in settings to fully
|
||||||
|
remove the feature.
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Plugin: backend.
|
||||||
|
*
|
||||||
|
* Creates the email_accounts table on first load, encrypts passwords with
|
||||||
|
* the existing vault key, and registers /api/email/* routes via the plugin
|
||||||
|
* route hook.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/email/accounts → list accounts (no passwords)
|
||||||
|
* POST /api/email/accounts → create account
|
||||||
|
* PATCH /api/email/accounts/{id} → update (omit password to keep)
|
||||||
|
* DELETE /api/email/accounts/{id} → remove
|
||||||
|
* POST /api/email/accounts/{id}/test → test IMAP credentials
|
||||||
|
* GET /api/email/messages?account={id}&limit=N → list recent message headers
|
||||||
|
* GET /api/email/message/{accId}/{uid} → fetch + parse one message
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/ImapClient.php';
|
||||||
|
require_once __DIR__ . '/SpamDetector.php';
|
||||||
|
|
||||||
|
// ── Schema migration ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS email_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 993,
|
||||||
|
encryption TEXT NOT NULL DEFAULT 'ssl',
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
folder TEXT NOT NULL DEFAULT 'INBOX',
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS email_spam_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rule_type TEXT NOT NULL CHECK(rule_type IN ('block','allow')),
|
||||||
|
sender_email TEXT,
|
||||||
|
sender_domain TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_spam_rules_email ON email_spam_rules(sender_email)");
|
||||||
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_spam_rules_domain ON email_spam_rules(sender_domain)");
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function emailEncKey(): string {
|
||||||
|
$db = Database::connect();
|
||||||
|
$vk = $db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn();
|
||||||
|
if ($vk) return substr(hash('sha256', $vk), 0, 32);
|
||||||
|
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||||
|
return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailEncrypt(string $plain): string {
|
||||||
|
$key = emailEncKey();
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailDecrypt(string $encoded): string {
|
||||||
|
$key = emailEncKey();
|
||||||
|
$raw = base64_decode($encoded);
|
||||||
|
if (!$raw || strlen($raw) < 17) return '';
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
return openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv) ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailJson($data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailGetAccount(int $id): ?array {
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('SELECT * FROM email_accounts WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
return $stmt->fetch() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailLoadSpamRules(): array {
|
||||||
|
$db = Database::connect();
|
||||||
|
return $db->query('SELECT rule_type, sender_email, sender_domain FROM email_spam_rules')->fetchAll() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free email providers — for these we store the specific email address
|
||||||
|
* (not the domain) when blocklisting, otherwise we'd block all gmail users.
|
||||||
|
*/
|
||||||
|
const EMAIL_FREE_PROVIDERS = [
|
||||||
|
'gmail.com', 'googlemail.com',
|
||||||
|
'outlook.com', 'hotmail.com', 'live.com', 'live.de', 'msn.com',
|
||||||
|
'yahoo.com', 'yahoo.de', 'yahoo.co.uk', 'ymail.com',
|
||||||
|
'icloud.com', 'me.com', 'mac.com',
|
||||||
|
'gmx.de', 'gmx.net', 'gmx.com', 'gmx.at', 'gmx.ch',
|
||||||
|
'web.de', 't-online.de', 'freenet.de', 'arcor.de',
|
||||||
|
'aol.com', 'aol.de',
|
||||||
|
'protonmail.com', 'proton.me', 'pm.me',
|
||||||
|
'mail.com', 'mail.ru', 'yandex.com', 'yandex.ru',
|
||||||
|
'tutanota.com', 'tuta.io', 'fastmail.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
function emailExtractSenderParts(string $fromHeader): array {
|
||||||
|
if (preg_match('/<([^>]+)>/', $fromHeader, $m)) {
|
||||||
|
$email = strtolower(trim($m[1]));
|
||||||
|
} elseif (preg_match('/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i', $fromHeader, $m)) {
|
||||||
|
$email = strtolower(trim($m[0]));
|
||||||
|
} else {
|
||||||
|
return ['email' => '', 'domain' => ''];
|
||||||
|
}
|
||||||
|
$at = strrpos($email, '@');
|
||||||
|
$domain = $at === false ? '' : substr($email, $at + 1);
|
||||||
|
return ['email' => $email, 'domain' => $domain];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a connected, logged-in IMAP client for the given account. */
|
||||||
|
function emailOpenAccount(array $row): ImapClient {
|
||||||
|
$client = new ImapClient(
|
||||||
|
$row['host'],
|
||||||
|
(int)$row['port'],
|
||||||
|
$row['encryption'],
|
||||||
|
15
|
||||||
|
);
|
||||||
|
$client->connect();
|
||||||
|
$client->login(emailDecrypt($row['username']), emailDecrypt($row['password']));
|
||||||
|
$client->selectFolder($row['folder'] ?: 'INBOX');
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route registration ────────────────────────────────────────────
|
||||||
|
|
||||||
|
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||||
|
|
||||||
|
// ── Accounts CRUD ──
|
||||||
|
|
||||||
|
if ($route === '/api/email/accounts' && $method === 'GET') {
|
||||||
|
$db = Database::connect();
|
||||||
|
$rows = $db->query('SELECT id, name, host, port, encryption, folder, position, created_at FROM email_accounts ORDER BY position ASC, id ASC')->fetchAll();
|
||||||
|
// Return decrypted username (NOT password) so the form can show it
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$stmt = $db->prepare('SELECT username FROM email_accounts WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $r['id']]);
|
||||||
|
$u = $stmt->fetchColumn();
|
||||||
|
$r['username'] = $u ? emailDecrypt($u) : '';
|
||||||
|
}
|
||||||
|
emailJson($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/email/accounts' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$name = trim((string)($input['name'] ?? ''));
|
||||||
|
$host = trim((string)($input['host'] ?? ''));
|
||||||
|
$username = (string)($input['username'] ?? '');
|
||||||
|
$password = (string)($input['password'] ?? '');
|
||||||
|
$port = (int)($input['port'] ?? 993);
|
||||||
|
$encryption = in_array($input['encryption'] ?? 'ssl', ['ssl', 'tls', 'none'], true) ? $input['encryption'] : 'ssl';
|
||||||
|
$folder = trim((string)($input['folder'] ?? 'INBOX')) ?: 'INBOX';
|
||||||
|
if ($name === '' || $host === '' || $username === '' || $password === '') {
|
||||||
|
emailJson(['error' => 'name, host, username, and password are required'], 400);
|
||||||
|
}
|
||||||
|
$db = Database::connect();
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO email_accounts (name, host, port, encryption, username, password, folder)
|
||||||
|
VALUES (:n, :h, :p, :e, :u, :pw, :f)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
'n' => $name, 'h' => $host, 'p' => $port, 'e' => $encryption,
|
||||||
|
'u' => emailEncrypt($username), 'pw' => emailEncrypt($password), 'f' => $folder,
|
||||||
|
]);
|
||||||
|
emailJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/email/accounts/(\d+)$#', $route, $m) && $method === 'PATCH') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$row = emailGetAccount($id);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$fields = []; $params = ['id' => $id];
|
||||||
|
foreach (['name', 'host', 'folder'] as $k) {
|
||||||
|
if (array_key_exists($k, $input)) { $fields[] = "$k = :$k"; $params[$k] = trim((string)$input[$k]); }
|
||||||
|
}
|
||||||
|
if (array_key_exists('port', $input)) { $fields[] = 'port = :port'; $params['port'] = (int)$input['port']; }
|
||||||
|
if (array_key_exists('encryption', $input) && in_array($input['encryption'], ['ssl','tls','none'], true)) {
|
||||||
|
$fields[] = 'encryption = :encryption'; $params['encryption'] = $input['encryption'];
|
||||||
|
}
|
||||||
|
if (!empty($input['username'])) { $fields[] = 'username = :username'; $params['username'] = emailEncrypt($input['username']); }
|
||||||
|
if (!empty($input['password'])) { $fields[] = 'password = :password'; $params['password'] = emailEncrypt($input['password']); }
|
||||||
|
if (!$fields) emailJson(['ok' => true]);
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('UPDATE email_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
|
||||||
|
emailJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/email/accounts/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$db = Database::connect();
|
||||||
|
$db->prepare('DELETE FROM email_accounts WHERE id = :id')->execute(['id' => $id]);
|
||||||
|
emailJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/email/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') {
|
||||||
|
$id = (int)$m[1];
|
||||||
|
$row = emailGetAccount($id);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$info = $client->selectFolder($row['folder'] ?: 'INBOX');
|
||||||
|
$client->logout();
|
||||||
|
emailJson(['ok' => true, 'exists' => $info['exists']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messages ──
|
||||||
|
|
||||||
|
if ($route === '/api/email/messages' && $method === 'GET') {
|
||||||
|
$accId = (int)($_GET['account'] ?? 0);
|
||||||
|
$limit = max(1, min(200, (int)($_GET['limit'] ?? 30)));
|
||||||
|
$offset = max(0, (int)($_GET['offset'] ?? 0));
|
||||||
|
$row = emailGetAccount($accId);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$uids = $client->uidSearch('ALL');
|
||||||
|
rsort($uids); // newest first
|
||||||
|
$total = count($uids);
|
||||||
|
$pageUids = array_slice($uids, $offset, $limit);
|
||||||
|
$messages = $client->fetchHeaders($pageUids);
|
||||||
|
usort($messages, fn($a, $b) => $b['uid'] - $a['uid']);
|
||||||
|
$client->logout();
|
||||||
|
|
||||||
|
// Score each message for spam (header signals only at list time —
|
||||||
|
// body checks happen on demand when the message is opened).
|
||||||
|
// Pass user-defined block/allow rules so manual feedback flows in.
|
||||||
|
$detector = new SpamDetector(emailLoadSpamRules());
|
||||||
|
foreach ($messages as &$msg) {
|
||||||
|
$verdict = $detector->score($msg['headers'] ?? []);
|
||||||
|
$msg['spam'] = $verdict;
|
||||||
|
// Don't ship the full headers map to the client (too noisy)
|
||||||
|
unset($msg['headers']);
|
||||||
|
}
|
||||||
|
unset($msg);
|
||||||
|
|
||||||
|
emailJson([
|
||||||
|
'ok' => true,
|
||||||
|
'messages' => $messages,
|
||||||
|
'total' => $total,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/email/spam/mark' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$accId = (int)($input['account'] ?? 0);
|
||||||
|
$uids = $input['uids'] ?? [];
|
||||||
|
$action = $input['action'] ?? '';
|
||||||
|
if (!in_array($action, ['spam', 'ham'], true)) {
|
||||||
|
emailJson(['error' => 'action must be spam or ham'], 400);
|
||||||
|
}
|
||||||
|
if (!is_array($uids) || !$uids) emailJson(['error' => 'uids array required'], 400);
|
||||||
|
$cleanUids = array_values(array_filter(array_map('intval', $uids), fn($u) => $u > 0));
|
||||||
|
if (!$cleanUids) emailJson(['error' => 'no valid uids'], 400);
|
||||||
|
$row = emailGetAccount($accId);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the From header for each UID
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$headers = $client->fetchHeaders($cleanUids);
|
||||||
|
$client->logout();
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$ruleType = $action === 'spam' ? 'block' : 'allow';
|
||||||
|
// Remove any opposing rule for the same sender first so toggling works
|
||||||
|
$oppositeType = $action === 'spam' ? 'allow' : 'block';
|
||||||
|
$delStmt = $db->prepare('DELETE FROM email_spam_rules WHERE rule_type = :t AND (sender_email = :e OR sender_domain = :d)');
|
||||||
|
$insStmt = $db->prepare('INSERT INTO email_spam_rules (rule_type, sender_email, sender_domain) VALUES (:t, :e, :d)');
|
||||||
|
|
||||||
|
$added = 0;
|
||||||
|
$seenSenders = [];
|
||||||
|
foreach ($headers as $h) {
|
||||||
|
$parts = emailExtractSenderParts($h['from'] ?? '');
|
||||||
|
if (!$parts['email']) continue;
|
||||||
|
|
||||||
|
// Dedupe within this request
|
||||||
|
$sigKey = $parts['email'];
|
||||||
|
if (isset($seenSenders[$sigKey])) continue;
|
||||||
|
$seenSenders[$sigKey] = true;
|
||||||
|
|
||||||
|
$isFreeProvider = in_array($parts['domain'], EMAIL_FREE_PROVIDERS, true);
|
||||||
|
// For free providers, only store the email; otherwise store both
|
||||||
|
$email = $parts['email'];
|
||||||
|
$domain = $isFreeProvider ? null : $parts['domain'];
|
||||||
|
|
||||||
|
// Drop any opposite rule
|
||||||
|
$delStmt->execute(['t' => $oppositeType, 'e' => $email, 'd' => $domain]);
|
||||||
|
// Avoid duplicate rule of the SAME type
|
||||||
|
$existsStmt = $db->prepare('SELECT 1 FROM email_spam_rules WHERE rule_type = :t AND ((sender_email IS NOT NULL AND sender_email = :e) OR (sender_domain IS NOT NULL AND sender_domain = :d)) LIMIT 1');
|
||||||
|
$existsStmt->execute(['t' => $ruleType, 'e' => $email, 'd' => $domain]);
|
||||||
|
if (!$existsStmt->fetchColumn()) {
|
||||||
|
$insStmt->execute(['t' => $ruleType, 'e' => $email, 'd' => $domain]);
|
||||||
|
$added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emailJson(['ok' => true, 'added' => $added, 'action' => $action]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/email/delete' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$accId = (int)($input['account'] ?? 0);
|
||||||
|
$uids = $input['uids'] ?? [];
|
||||||
|
if (!is_array($uids) || !$uids) {
|
||||||
|
emailJson(['error' => 'uids array required'], 400);
|
||||||
|
}
|
||||||
|
$cleanUids = array_values(array_filter(array_map('intval', $uids), fn($u) => $u > 0));
|
||||||
|
if (!$cleanUids) emailJson(['error' => 'no valid uids'], 400);
|
||||||
|
$row = emailGetAccount($accId);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$count = $client->deleteUids($cleanUids);
|
||||||
|
$client->logout();
|
||||||
|
emailJson(['ok' => true, 'deleted' => $count]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/api/email/message/(\d+)/(\d+)$#', $route, $m) && $method === 'GET') {
|
||||||
|
$accId = (int)$m[1];
|
||||||
|
$uid = (int)$m[2];
|
||||||
|
$row = emailGetAccount($accId);
|
||||||
|
if (!$row) emailJson(['error' => 'account not found'], 404);
|
||||||
|
try {
|
||||||
|
$client = emailOpenAccount($row);
|
||||||
|
$msg = $client->fetchMessage($uid);
|
||||||
|
$client->logout();
|
||||||
|
// Strip HTML if no plaintext available
|
||||||
|
if (!$msg['text'] && $msg['html']) {
|
||||||
|
$msg['text'] = trim(html_entity_decode(strip_tags(preg_replace('/<br\s*\/?>/i', "\n", $msg['html']))));
|
||||||
|
}
|
||||||
|
emailJson([
|
||||||
|
'ok' => true,
|
||||||
|
'uid' => $uid,
|
||||||
|
'subject' => $msg['headers']['subject'] ?? '',
|
||||||
|
'from' => $msg['headers']['from'] ?? '',
|
||||||
|
'to' => $msg['headers']['to'] ?? '',
|
||||||
|
'date' => $msg['headers']['date'] ?? '',
|
||||||
|
'text' => $msg['text'],
|
||||||
|
'html' => $msg['html'],
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
emailJson(['ok' => false, 'error' => $e->getMessage()], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/* Email Plugin styles */
|
||||||
|
|
||||||
|
#email-overlay { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); }
|
||||||
|
|
||||||
|
.email-modal {
|
||||||
|
/* defined inline for sizing */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message list — no text selection inside (drag is for multi-select) */
|
||||||
|
#email-list, #email-list * {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message list rows */
|
||||||
|
.email-msg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.email-msg-row:hover {
|
||||||
|
background: var(--t-raised);
|
||||||
|
}
|
||||||
|
.email-msg-row.active {
|
||||||
|
background: var(--t-raised);
|
||||||
|
border-left-color: var(--t-accent);
|
||||||
|
}
|
||||||
|
.email-msg-row.unseen {
|
||||||
|
border-left-color: var(--t-accent2);
|
||||||
|
}
|
||||||
|
.email-msg-row.selected {
|
||||||
|
background: color-mix(in srgb, var(--t-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spam levels — suspicious yellow, spam red. Override unseen border. */
|
||||||
|
.email-msg-row.spam-suspicious {
|
||||||
|
border-left-color: #fbbf24 !important; /* amber */
|
||||||
|
background: color-mix(in srgb, #fbbf24 8%, transparent);
|
||||||
|
}
|
||||||
|
.email-msg-row.spam-suspicious:hover {
|
||||||
|
background: color-mix(in srgb, #fbbf24 14%, var(--t-raised));
|
||||||
|
}
|
||||||
|
.email-msg-row.spam-spam {
|
||||||
|
border-left-color: var(--t-danger) !important;
|
||||||
|
background: color-mix(in srgb, var(--t-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
.email-msg-row.spam-spam:hover {
|
||||||
|
background: color-mix(in srgb, var(--t-danger) 20%, var(--t-raised));
|
||||||
|
}
|
||||||
|
.email-msg-row.spam-spam .email-msg-subject {
|
||||||
|
color: var(--t-danger);
|
||||||
|
}
|
||||||
|
.email-spam-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.email-spam-badge.spam {
|
||||||
|
background: var(--t-danger);
|
||||||
|
color: var(--t-bg);
|
||||||
|
}
|
||||||
|
.email-spam-badge.suspicious {
|
||||||
|
background: #fbbf24;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.email-msg-check {
|
||||||
|
margin-top: 2px;
|
||||||
|
accent-color: var(--t-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.email-msg-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.email-msg-row.unseen .email-msg-subject {
|
||||||
|
color: var(--t-bright);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.email-msg-from {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.email-msg-subject {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--t-text);
|
||||||
|
margin-top: 2px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.email-msg-date {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Account list (in management view) */
|
||||||
|
.email-account-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
background: var(--t-surface);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.email-account-row .email-account-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.email-account-row .email-account-name {
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-bright);
|
||||||
|
}
|
||||||
|
.email-account-row .email-account-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.email-account-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading / empty states */
|
||||||
|
.email-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--t-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--t-font-pixel);
|
||||||
|
}
|
||||||
|
|
||||||
|
#email-divider:hover > div { background: var(--t-accent); width: 1px; }
|
||||||
|
|
||||||
|
/* Override Chrome/Safari autofill styling on the account form inputs */
|
||||||
|
#email-acc-username:-webkit-autofill,
|
||||||
|
#email-acc-username:-webkit-autofill:hover,
|
||||||
|
#email-acc-username:-webkit-autofill:focus,
|
||||||
|
#email-acc-password:-webkit-autofill,
|
||||||
|
#email-acc-password:-webkit-autofill:hover,
|
||||||
|
#email-acc-password:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: var(--t-bright);
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||||
|
box-shadow: 0 0 0 1000px var(--t-bg) inset;
|
||||||
|
caret-color: var(--t-bright);
|
||||||
|
transition: background-color 9999s ease-out, color 9999s ease-out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<!-- Email Plugin: full-screen mail viewer overlay -->
|
||||||
|
<div class="overlay fixed inset-0" id="email-overlay" style="z-index:75;">
|
||||||
|
<div class="email-modal bg-t-bg border border-t-border shadow-2xl flex flex-col" style="position:absolute;inset:24px;border-radius:4px;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2 border-b border-t-border shrink-0 bg-t-surface">
|
||||||
|
<i class="ph ph-envelope text-t-accent text-base"></i>
|
||||||
|
<h2 class="font-pixel text-xs text-t-accent">mail</h2>
|
||||||
|
<select id="email-account-select" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer min-w-32"></select>
|
||||||
|
<button id="email-refresh" class="toolbar-btn" title="Refresh"><i class="ph ph-arrows-clockwise text-sm"></i></button>
|
||||||
|
<span id="email-status" class="text-[10px] text-t-dim font-pixel"></span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button id="email-show-settings" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Manage accounts in settings"><i class="ph ph-gear text-sm"></i><span class="font-pixel text-[10px]">accounts</span></button>
|
||||||
|
<button id="email-close" class="flex items-center gap-1.5 px-2 py-1 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]">close</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Welcome view (shown when no accounts) ── -->
|
||||||
|
<div id="email-view-welcome" class="hidden flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-center h-full px-6 py-12">
|
||||||
|
<div class="email-welcome text-center max-w-md">
|
||||||
|
<i class="ph ph-envelope text-t-accent text-6xl block mb-4"></i>
|
||||||
|
<h3 class="font-pixel text-base text-t-accent tracking-wider mb-2">welcome to mail</h3>
|
||||||
|
<p class="text-xs text-t-muted leading-relaxed mb-6">
|
||||||
|
Read mail from any IMAP account inside hyprcrd. Messages stay on the
|
||||||
|
server — only the ones you explicitly extract become cards.
|
||||||
|
To get started, add at least one account in settings.
|
||||||
|
</p>
|
||||||
|
<button id="email-welcome-configure" class="inline-flex items-center gap-2 px-4 py-2 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all">
|
||||||
|
<i class="ph ph-gear text-sm"></i>
|
||||||
|
<span class="font-pixel text-[11px]">configure accounts</span>
|
||||||
|
</button>
|
||||||
|
<p class="text-[10px] text-t-dim mt-4 font-pixel">settings → mail</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Mail viewer (default view when accounts exist) ── -->
|
||||||
|
<div id="email-view-mail" class="hidden flex-1 min-h-0 overflow-hidden">
|
||||||
|
<!-- Message list -->
|
||||||
|
<div id="email-list-pane" class="overflow-y-auto border-r border-t-border bg-t-bg shrink-0 relative" style="width:38%;">
|
||||||
|
<div class="sticky top-0 z-20 bg-t-bg">
|
||||||
|
<!-- List toolbar: pagination + spam quick-select -->
|
||||||
|
<div id="email-list-toolbar" class="hidden flex items-center gap-2 px-3 py-1.5 border-b border-t-border">
|
||||||
|
<button id="email-page-prev" class="toolbar-btn" title="Previous page"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
|
<button id="email-page-next" class="toolbar-btn" title="Next page"><i class="ph ph-caret-right text-sm"></i></button>
|
||||||
|
<span id="email-page-info" class="font-pixel text-[9px] text-t-dim">—</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button id="email-select-spam" class="hidden flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Select all messages flagged as spam">
|
||||||
|
<i class="ph ph-warning-octagon text-sm"></i>
|
||||||
|
<span class="font-pixel text-[9px]">select spam (<span id="email-spam-count">0</span>)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Selection action bar (visible when at least one selected) -->
|
||||||
|
<div id="email-selection-bar" class="hidden flex items-center gap-2 px-3 py-2 border-b border-t-accent bg-t-raised">
|
||||||
|
<span id="email-selection-count" class="font-pixel text-[10px] text-t-accent">0 selected</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button id="email-selection-mark-spam" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Mark sender as spam (future mail from same sender will be flagged)">
|
||||||
|
<i class="ph ph-warning-octagon text-sm"></i><span class="font-pixel text-[9px]">spam</span>
|
||||||
|
</button>
|
||||||
|
<button id="email-selection-mark-ham" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors" title="Mark sender as not spam (allowlist)">
|
||||||
|
<i class="ph ph-check-circle text-sm"></i><span class="font-pixel text-[9px]">not spam</span>
|
||||||
|
</button>
|
||||||
|
<button id="email-selection-delete" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger/10 transition-colors" title="Delete selected on server">
|
||||||
|
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[9px]">delete</span>
|
||||||
|
</button>
|
||||||
|
<button id="email-selection-clear" class="flex items-center gap-1 px-2 py-0.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Clear selection">
|
||||||
|
<i class="ph ph-x text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="email-list" class="divide-y divide-t-border"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Resizer -->
|
||||||
|
<div id="email-divider" class="shrink-0" style="width:5px;cursor:col-resize;background:transparent;position:relative;">
|
||||||
|
<div style="position:absolute;left:2px;top:0;bottom:0;width:1px;background:var(--t-border);"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Message body -->
|
||||||
|
<div id="email-body-pane" class="flex-1 min-w-0 min-h-0 flex flex-col bg-t-bg">
|
||||||
|
<div id="email-body-empty" class="flex items-center justify-center flex-1 text-t-dim text-xs font-pixel">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="ph ph-envelope-open text-3xl mb-2 block"></i>
|
||||||
|
select a message
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="email-body" class="hidden flex flex-col flex-1 min-h-0">
|
||||||
|
<div class="shrink-0 border-b border-t-border px-5 pt-5 pb-3">
|
||||||
|
<h3 id="email-msg-subject" class="text-t-bright text-base font-bold mb-2"></h3>
|
||||||
|
<div class="flex items-center gap-3 text-[10px] font-pixel text-t-dim">
|
||||||
|
<span><span class="text-t-muted">from:</span> <span id="email-msg-from" class="text-t-text"></span></span>
|
||||||
|
<span><span class="text-t-muted">date:</span> <span id="email-msg-date" class="text-t-text"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<button id="email-extract-btn" class="flex items-center gap-1.5 px-3 py-1 text-xs bg-t-accent text-t-bg border border-t-accent hover:brightness-110 transition-all"><i class="ph ph-scissors text-sm"></i><span class="font-pixel text-[10px]">extract as card</span></button>
|
||||||
|
<button id="email-view-html" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Show HTML rendered version (external images blocked by default)"><i class="ph ph-code text-sm"></i><span class="font-pixel text-[10px]">view as html</span></button>
|
||||||
|
<button id="email-view-text" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" title="Switch back to plain text"><i class="ph ph-text-aa text-sm"></i><span class="font-pixel text-[10px]">view as text</span></button>
|
||||||
|
<button id="email-load-images" class="hidden items-center gap-1.5 px-3 py-1 text-xs border border-t-accent2 text-t-accent2 hover:bg-t-accent2/10 transition-colors" title="Load external images (privacy: tracking pixels may be loaded)"><i class="ph ph-image text-sm"></i><span class="font-pixel text-[10px]">load images (<span id="email-blocked-count">0</span>)</span></button>
|
||||||
|
<span id="email-extract-status" class="font-pixel text-[9px] text-t-dim"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre id="email-msg-text" class="flex-1 min-h-0 overflow-y-auto text-xs text-t-text whitespace-pre-wrap break-words font-mono leading-relaxed px-5 py-4 m-0"></pre>
|
||||||
|
<iframe id="email-msg-html" class="hidden flex-1 min-h-0 w-full bg-white" sandbox="allow-popups allow-popups-to-escape-sandbox" style="border:none;"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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 =>
|
||||||
|
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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',
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Read IMAP mail accounts in a split-pane viewer. Extract messages as cards. Multiple accounts supported, passwords encrypted with the vault key.",
|
||||||
|
"frontend": {
|
||||||
|
"js": ["email.js"],
|
||||||
|
"css": ["email.css"],
|
||||||
|
"html": ["email.html"]
|
||||||
|
},
|
||||||
|
"backend": "backend.php"
|
||||||
|
}
|
||||||
@@ -9,11 +9,12 @@ describe('Settings: Open/Close', () => {
|
|||||||
expect($('#settings-overlay')).toBeVisible();
|
expect($('#settings-overlay')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has all 8 tabs', async () => {
|
it('has all core settings tabs', async () => {
|
||||||
click('#settings-btn');
|
click('#settings-btn');
|
||||||
await sleep(100);
|
await sleep(100);
|
||||||
const names = $$('.settings-tab').map(t => t.dataset.tab);
|
const names = $$('.settings-tab').map(t => t.dataset.tab);
|
||||||
expect(names.length).toBe(8);
|
// Plugins may add their own tabs, so we don't hardcode a count;
|
||||||
|
// we just verify every core tab is present.
|
||||||
for (const tab of ['branding', 'account', 'theme', 'caldav', 'relay', 'behavior', 'notifications', 'plugins']) {
|
for (const tab of ['branding', 'account', 'theme', 'caldav', 'relay', 'behavior', 'notifications', 'plugins']) {
|
||||||
expect(names).toContain(tab);
|
expect(names).toContain(tab);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class PluginManager {
|
|||||||
private array $plugins = [];
|
private array $plugins = [];
|
||||||
private array $slots = [];
|
private array $slots = [];
|
||||||
private array $disabled = []; // names of plugins the user has disabled
|
private array $disabled = []; // names of plugins the user has disabled
|
||||||
|
private array $routeHandlers = []; // callbacks: fn(string $route, string $method): bool
|
||||||
private ?string $stateFile = null; // path to plugins.json
|
private ?string $stateFile = null; // path to plugins.json
|
||||||
private static ?PluginManager $instance = null;
|
private static ?PluginManager $instance = null;
|
||||||
|
|
||||||
@@ -99,6 +100,32 @@ class PluginManager {
|
|||||||
return $this->disabled;
|
return $this->disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a route handler from a plugin's backend.php.
|
||||||
|
* The callback receives ($route, $method) and should call exit() on a match.
|
||||||
|
* If it doesn't exit, the next handler / core route handling continues.
|
||||||
|
*/
|
||||||
|
public function onApiRoute(callable $handler): void {
|
||||||
|
$this->routeHandlers[] = $handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a request to all registered plugin route handlers in order.
|
||||||
|
* Each handler that wants to claim the route must call exit() itself.
|
||||||
|
* Called from index.php BEFORE core route handling.
|
||||||
|
*/
|
||||||
|
public function dispatchRoute(string $route, string $method): void {
|
||||||
|
foreach ($this->routeHandlers as $handler) {
|
||||||
|
try { $handler($route, $method); }
|
||||||
|
catch (\Throwable $e) {
|
||||||
|
error_log('[plugin route] ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'plugin route handler failed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Public-friendly plugin list for the API: [{name, version, description, enabled}]. */
|
/** Public-friendly plugin list for the API: [{name, version, description, enabled}]. */
|
||||||
public function pluginsForApi(): array {
|
public function pluginsForApi(): array {
|
||||||
$out = [];
|
$out = [];
|
||||||
|
|||||||
Reference in new issue
Block a user