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] ?? [])));
|
||||
}
|
||||
|
||||
// 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
|
||||
if ($route === '/api/cards' && $method === 'POST') {
|
||||
$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() {
|
||||
const res = await fetch('api/auth/status');
|
||||
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) {
|
||||
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 d = await r.json();
|
||||
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) {
|
||||
let ok = false;
|
||||
while (!ok) {
|
||||
@@ -189,7 +189,8 @@ async function checkAuth() {
|
||||
if (!pw) continue;
|
||||
const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||
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) ───────────────────
|
||||
let csrfToken = '';
|
||||
const util = {
|
||||
$(idOrSel) {
|
||||
if (typeof idOrSel !== 'string') return null;
|
||||
@@ -114,6 +115,105 @@
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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 ────────────────────────────────────
|
||||
@@ -128,6 +228,9 @@
|
||||
editor,
|
||||
markdown,
|
||||
util,
|
||||
api,
|
||||
settings,
|
||||
dialog,
|
||||
plugins,
|
||||
registerPlugin,
|
||||
version: '1.0',
|
||||
|
||||
@@ -150,6 +150,11 @@ export function showDialog({ title, message = '', input = false, placeholder = '
|
||||
|
||||
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);
|
||||
cancelBtn.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();
|
||||
});
|
||||
|
||||
it('has all 8 tabs', async () => {
|
||||
it('has all core settings tabs', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
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']) {
|
||||
expect(names).toContain(tab);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class PluginManager {
|
||||
private array $plugins = [];
|
||||
private array $slots = [];
|
||||
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 static ?PluginManager $instance = null;
|
||||
|
||||
@@ -99,6 +100,32 @@ class PluginManager {
|
||||
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 function pluginsForApi(): array {
|
||||
$out = [];
|
||||
|
||||
Reference in new issue
Block a user