email plugin

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

No files matched your search

Submodule gitea-issue-client added at 3d0c2f3a82.
+4
View File
@@ -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
View File
@@ -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 */ }
}
}
+103
View File
@@ -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 @@
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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',
+5
View File
@@ -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); });
+294
View File
@@ -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)) ?: '';
}
```
+472
View File
@@ -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;
}
}
+332
View File
@@ -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));
}
}
+109
View File
@@ -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.
+377
View File
@@ -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);
}
}
});
+172
View File
@@ -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;
}
+106
View File
@@ -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>
+959
View File
@@ -0,0 +1,959 @@
// ── Email Plugin Frontend ──
//
// Self-registering plugin:
// - Injects header button "mail"
// - Registers a "mail" tab in core settings (accounts management lives there)
// - Mail overlay shows welcome view when no accounts exist
// - Otherwise shows split-pane reader
// - Extracts messages as cards via /api/cards
const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[email plugin] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
// ── State ─────────────────────────────────────────────────────────
let accounts = [];
let currentAccountId = null;
let currentMessages = [];
let currentMessage = null;
let editingAccountId = null;
let selectedUids = new Set();
// Pagination state
let currentOffset = 0;
let currentTotal = 0;
const PAGE_SIZE_KEY = 'email_messages_per_page';
const PAGE_SIZE_DEFAULT = 30;
const getPageSize = () => {
const v = parseInt(localStorage.getItem(PAGE_SIZE_KEY) || '');
return Number.isFinite(v) && v >= 5 && v <= 200 ? v : PAGE_SIZE_DEFAULT;
};
const setPageSize = (n) => {
const clamped = Math.max(5, Math.min(200, parseInt(n) || PAGE_SIZE_DEFAULT));
localStorage.setItem(PAGE_SIZE_KEY, String(clamped));
return clamped;
};
// Drag-select state for the message list
let dragActive = false;
let dragStartUid = null;
let dragMoved = false; // true once mouse entered another row
let dragMode = 'add'; // 'add' or 'remove' — based on start row's initial state
// ── Overlay open / close ──────────────────────────────────────────
async function openEmail() {
$('email-overlay').classList.add('open');
selectedUids.clear();
currentOffset = 0;
await loadAccounts();
if (accounts.length === 0) {
showWelcomeView();
} else {
// If currentAccountId still valid, keep it; otherwise reset
if (!accounts.find(a => a.id === currentAccountId)) {
currentAccountId = accounts[0].id;
}
$('email-account-select').value = currentAccountId;
showMailView();
await loadMessages();
}
}
function closeEmail() {
$('email-overlay').classList.remove('open');
}
function showWelcomeView() {
// Welcome is a plain block; the inner wrapper does flex centering with h-full
$('email-view-welcome').classList.remove('hidden');
$('email-view-mail').classList.add('hidden');
$('email-view-mail').classList.remove('flex');
$('email-status').textContent = '';
}
function showMailView() {
$('email-view-welcome').classList.add('hidden');
$('email-view-welcome').classList.remove('flex');
$('email-view-mail').classList.remove('hidden');
$('email-view-mail').classList.add('flex');
}
function gotoAccountsSettings() {
closeEmail();
hyprcrd.settings.openTab('email');
}
// ── Accounts: data ────────────────────────────────────────────────
async function loadAccounts() {
const res = await fetch('api/email/accounts');
accounts = await res.json();
if (!Array.isArray(accounts)) accounts = [];
renderAccountSelect();
}
function renderAccountSelect() {
const sel = $('email-account-select');
if (!sel) return;
sel.innerHTML = accounts.length
? accounts.map(a => `<option value="${a.id}">${escapeHtml(a.name)}</option>`).join('')
: '<option value="">— no accounts —</option>';
}
// ── Settings tab: rendering ───────────────────────────────────────
function buildSettingsPanel(panelEl) {
panelEl.innerHTML = `
<div class="space-y-4">
<div>
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-sliders text-xs"></i> preferences</div>
<div class="flex items-center gap-3">
<label class="font-pixel text-[9px] text-t-muted uppercase">messages per page</label>
<input id="email-page-size" type="number" min="5" max="200" value="${getPageSize()}" class="w-20 bg-t-bg border border-t-border text-t-bright px-2 py-1 text-xs focus:border-t-accent focus:outline-none font-mono text-center">
<span id="email-page-size-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
<p class="text-[10px] text-t-dim mt-1">Number of mails fetched per page (5–200). Affects all accounts.</p>
</div>
<div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-envelope text-xs"></i> mail accounts</div>
<p class="text-[10px] text-t-dim leading-relaxed mb-3">
Configure IMAP accounts to read mail inside hyprcrd. Passwords are encrypted with the vault key.
For Gmail/Outlook, use an app password instead of your regular password.
</p>
<div id="email-accounts-list" class="space-y-2"></div>
</div>
<div class="border-t border-t-border pt-4">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-3"><span id="email-form-title">add account</span></div>
<form id="email-account-form" class="space-y-2">
<input type="hidden" id="email-acc-id" value="">
<div class="grid grid-cols-2 gap-2">
<label class="block">
<span class="font-pixel text-[9px] text-t-dim uppercase">name</span>
<input id="email-acc-name" type="text" placeholder="My Mail" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
<label class="block">
<span class="font-pixel text-[9px] text-t-dim uppercase">folder</span>
<input id="email-acc-folder" type="text" placeholder="INBOX" value="INBOX" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
</div>
<div class="grid grid-cols-3 gap-2">
<label class="block col-span-2">
<span class="font-pixel text-[9px] text-t-dim uppercase">imap host</span>
<input id="email-acc-host" type="text" placeholder="imap.example.com" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
<label class="block">
<span class="font-pixel text-[9px] text-t-dim uppercase">port</span>
<input id="email-acc-port" type="number" value="993" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
</div>
<label class="block">
<span class="font-pixel text-[9px] text-t-dim uppercase">encryption</span>
<select id="email-acc-encryption" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
<option value="ssl">SSL/TLS (port 993)</option>
<option value="none">None (plain, port 143)</option>
</select>
</label>
<label class="block">
<span class="font-pixel text-[9px] text-t-dim uppercase">username</span>
<input id="email-acc-username" type="text" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
<label class="block">
<span class="font-pixel text-[9px] text-t-dim uppercase">password / app password <span id="email-pw-hint" class="text-t-muted normal-case text-[8px]"></span></span>
<input id="email-acc-password" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
</label>
<div class="flex items-center gap-2 pt-1">
<button type="submit" id="email-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button type="button" id="email-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
<button type="button" id="email-acc-cancel" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">cancel</span></button>
<span id="email-acc-status" class="font-pixel text-[9px] text-t-dim"></span>
</div>
</form>
</div>
</div>
`;
panelEl.querySelector('#email-account-form').addEventListener('submit', saveAccount);
panelEl.querySelector('#email-acc-cancel').addEventListener('click', resetAccountForm);
panelEl.querySelector('#email-acc-test').addEventListener('click', testCurrentForm);
const pageSizeInput = panelEl.querySelector('#email-page-size');
const pageSizeStatus = panelEl.querySelector('#email-page-size-status');
const savePageSize = () => {
const newSize = setPageSize(pageSizeInput.value);
pageSizeInput.value = newSize;
pageSizeStatus.textContent = 'saved';
pageSizeStatus.style.color = 'var(--t-accent)';
setTimeout(() => { pageSizeStatus.textContent = ''; }, 1500);
// Reset to first page so the new size takes effect cleanly
currentOffset = 0;
};
pageSizeInput.addEventListener('change', savePageSize);
pageSizeInput.addEventListener('blur', savePageSize);
}
async function refreshSettingsPanel(panelEl) {
if (!panelEl.querySelector('#email-account-form')) {
buildSettingsPanel(panelEl);
}
await loadAccounts();
renderAccountsList();
resetAccountForm();
}
function renderAccountsList() {
const list = $('email-accounts-list');
if (!list) return;
if (!accounts.length) {
list.innerHTML = '<p class="text-xs text-t-dim italic">No accounts configured yet. Use the form below to add one.</p>';
return;
}
list.innerHTML = accounts.map(a => `
<div class="email-account-row" data-id="${a.id}">
<i class="ph ph-envelope text-t-accent text-base"></i>
<div class="email-account-info">
<div class="email-account-name">${escapeHtml(a.name)}</div>
<div class="email-account-meta">${escapeHtml(a.username || '')} @ ${escapeHtml(a.host)}:${a.port} · ${escapeHtml(a.encryption)}</div>
</div>
<div class="email-account-actions">
<button class="toolbar-btn email-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
<button class="toolbar-btn email-test-btn" title="Test"><i class="ph ph-plug text-sm"></i></button>
<button class="toolbar-btn email-del-btn" title="Delete"><i class="ph ph-trash text-sm"></i></button>
</div>
</div>
`).join('');
list.querySelectorAll('.email-account-row').forEach(row => {
const id = parseInt(row.dataset.id);
row.querySelector('.email-edit-btn').addEventListener('click', () => editAccount(id));
row.querySelector('.email-del-btn').addEventListener('click', () => deleteAccount(id));
row.querySelector('.email-test-btn').addEventListener('click', () => testExistingAccount(id));
});
}
function resetAccountForm() {
editingAccountId = null;
const form = $('email-account-form');
if (!form) return;
$('email-form-title').textContent = 'add account';
$('email-acc-id').value = '';
$('email-acc-name').value = '';
$('email-acc-host').value = '';
$('email-acc-port').value = '993';
$('email-acc-encryption').value = 'ssl';
$('email-acc-username').value = '';
$('email-acc-password').value = '';
$('email-acc-folder').value = 'INBOX';
$('email-acc-status').textContent = '';
$('email-pw-hint').textContent = '';
}
function editAccount(id) {
const acc = accounts.find(a => a.id === id);
if (!acc) return;
editingAccountId = id;
$('email-form-title').textContent = 'edit account: ' + acc.name;
$('email-acc-id').value = id;
$('email-acc-name').value = acc.name;
$('email-acc-host').value = acc.host;
$('email-acc-port').value = acc.port;
$('email-acc-encryption').value = acc.encryption;
$('email-acc-username').value = acc.username || '';
$('email-acc-password').value = '';
$('email-acc-folder').value = acc.folder;
$('email-pw-hint').textContent = '(leave blank to keep existing)';
$('email-acc-status').textContent = '';
$('email-acc-name').focus();
}
async function deleteAccount(id) {
const acc = accounts.find(a => a.id === id);
if (!acc) return;
const ok = await hyprcrd.dialog.confirm(
`Delete email account "${acc.name}"? Stored credentials will be removed but no mail is deleted from the server.`,
{ title: 'delete account', okLabel: 'delete', cancelLabel: 'cancel', danger: true }
);
if (!ok) return;
await hyprcrd.api(`api/email/accounts/${id}`, { method: 'DELETE' });
await loadAccounts();
renderAccountsList();
}
async function saveAccount(e) {
e.preventDefault();
const status = $('email-acc-status');
status.textContent = 'saving...';
status.style.color = 'var(--t-dim)';
const payload = {
name: $('email-acc-name').value.trim(),
host: $('email-acc-host').value.trim(),
port: parseInt($('email-acc-port').value) || 993,
encryption: $('email-acc-encryption').value,
username: $('email-acc-username').value,
password: $('email-acc-password').value,
folder: $('email-acc-folder').value.trim() || 'INBOX',
};
if (!payload.name || !payload.host || !payload.username || (!editingAccountId && !payload.password)) {
status.textContent = 'name, host, username, and password are required';
status.style.color = 'var(--t-danger)';
return;
}
if (editingAccountId && !payload.password) delete payload.password;
try {
let res;
if (editingAccountId) {
res = await hyprcrd.api(`api/email/accounts/${editingAccountId}`, { method: 'PATCH', body: payload });
} else {
res = await hyprcrd.api('api/email/accounts', { method: 'POST', body: payload });
}
if (res.error) {
status.textContent = 'error: ' + res.error;
status.style.color = 'var(--t-danger)';
return;
}
status.textContent = 'saved';
status.style.color = 'var(--t-accent)';
await loadAccounts();
renderAccountsList();
resetAccountForm();
} catch (err) {
status.textContent = 'error: ' + err.message;
status.style.color = 'var(--t-danger)';
}
}
async function testExistingAccount(id) {
const status = $('email-acc-status');
status.textContent = 'testing...';
status.style.color = 'var(--t-dim)';
try {
const res = await hyprcrd.api(`api/email/accounts/${id}/test`, { method: 'POST' });
if (res.ok) {
status.textContent = `ok — ${res.exists} messages in INBOX`;
status.style.color = 'var(--t-accent)';
} else {
status.textContent = 'failed: ' + (res.error || 'unknown');
status.style.color = 'var(--t-danger)';
}
} catch (err) {
status.textContent = 'error: ' + err.message;
status.style.color = 'var(--t-danger)';
}
}
async function testCurrentForm() {
if (!editingAccountId) {
const status = $('email-acc-status');
status.textContent = 'save first, then test';
status.style.color = 'var(--t-dim)';
return;
}
await testExistingAccount(editingAccountId);
}
// ── Messages ──────────────────────────────────────────────────────
async function loadMessages() {
if (!currentAccountId) return;
const list = $('email-list');
list.innerHTML = '<div class="email-loading-spinner flex items-center justify-center" style="min-height:60vh;"><i class="ph ph-spinner card-spinner" style="font-size:2.25rem;color:var(--t-muted);opacity:0.4;"></i></div>';
$('email-status').textContent = 'fetching...';
const limit = getPageSize();
try {
const res = await fetch(`api/email/messages?account=${currentAccountId}&limit=${limit}&offset=${currentOffset}`);
const data = await res.json();
if (!data.ok) {
list.innerHTML = `<div class="p-4 text-xs text-t-danger">${escapeHtml(data.error || 'failed to load')}</div>`;
$('email-status').textContent = 'error';
renderPagination();
return;
}
currentMessages = data.messages || [];
currentTotal = data.total ?? currentMessages.length;
// If a delete pushed us past the end, slide back one page
if (currentOffset > 0 && currentMessages.length === 0 && currentTotal > 0) {
currentOffset = Math.max(0, currentOffset - limit);
return loadMessages();
}
renderMessageList();
renderPagination();
$('email-status').textContent = `${currentMessages.length} message${currentMessages.length === 1 ? '' : 's'}`;
} catch (err) {
list.innerHTML = `<div class="p-4 text-xs text-t-danger">${escapeHtml(err.message)}</div>`;
$('email-status').textContent = 'error';
}
}
function renderPagination() {
const tb = $('email-list-toolbar');
if (!tb) return;
if (currentTotal === 0) {
tb.classList.add('hidden');
tb.classList.remove('flex');
return;
}
tb.classList.remove('hidden');
tb.classList.add('flex');
const limit = getPageSize();
const startIdx = currentOffset + 1;
const endIdx = Math.min(currentOffset + currentMessages.length, currentTotal);
$('email-page-info').textContent = `${startIdx}-${endIdx} of ${currentTotal}`;
const prevBtn = $('email-page-prev');
const nextBtn = $('email-page-next');
prevBtn.disabled = currentOffset <= 0;
nextBtn.disabled = currentOffset + limit >= currentTotal;
prevBtn.classList.toggle('opacity-30', prevBtn.disabled);
prevBtn.classList.toggle('cursor-not-allowed', prevBtn.disabled);
nextBtn.classList.toggle('opacity-30', nextBtn.disabled);
nextBtn.classList.toggle('cursor-not-allowed', nextBtn.disabled);
}
function gotoPrevPage() {
if (currentOffset <= 0) return;
currentOffset = Math.max(0, currentOffset - getPageSize());
selectedUids.clear();
loadMessages();
}
function gotoNextPage() {
if (currentOffset + getPageSize() >= currentTotal) return;
currentOffset += getPageSize();
selectedUids.clear();
loadMessages();
}
function renderMessageList() {
const list = $('email-list');
if (!currentMessages.length) {
list.innerHTML = '<div class="p-4 text-xs text-t-dim italic">no messages</div>';
renderSelectionBar();
renderSpamToolbar();
return;
}
list.innerHTML = currentMessages.map(m => {
const sel = selectedUids.has(m.uid);
const spamLevel = m.spam?.level || 'clean'; // 'clean' | 'suspicious' | 'spam'
const spamScore = m.spam?.score ?? 0;
const spamReasons = (m.spam?.reasons || []).join(' · ');
const spamBadge = spamLevel === 'spam'
? `<span class="email-spam-badge spam" title="${escapeHtml(spamReasons)}">⚠ spam</span>`
: spamLevel === 'suspicious'
? `<span class="email-spam-badge suspicious" title="${escapeHtml(spamReasons)}">? suspicious</span>`
: '';
const rowTitle = spamReasons ? `spam score ${spamScore}: ${spamReasons}` : '';
return `
<div class="email-msg-row ${m.seen ? '' : 'unseen'} ${sel ? 'selected' : ''} spam-${spamLevel}" data-uid="${m.uid}" title="${escapeHtml(rowTitle)}">
<input type="checkbox" class="email-msg-check" ${sel ? 'checked' : ''} aria-label="select message">
<div class="email-msg-content">
<div class="flex items-baseline justify-between gap-2">
<span class="email-msg-from">${escapeHtml(extractName(m.from))}</span>
<span class="email-msg-date">${escapeHtml(formatDate(m.date))}</span>
</div>
<div class="email-msg-subject">${spamBadge}${escapeHtml(m.subject || '(no subject)')}</div>
</div>
</div>
`;
}).join('');
list.querySelectorAll('.email-msg-row').forEach(row => {
const uid = parseInt(row.dataset.uid);
const cb = row.querySelector('.email-msg-check');
cb.addEventListener('click', e => e.stopPropagation()); // prevent row body click
cb.addEventListener('change', () => toggleSelect(uid, cb.checked));
const content = row.querySelector('.email-msg-content');
content.addEventListener('mousedown', e => {
if (e.button !== 0) return;
dragActive = true;
dragStartUid = uid;
dragMoved = false;
// If row is not selected → drag adds, if already selected → drag removes
dragMode = selectedUids.has(uid) ? 'remove' : 'add';
e.preventDefault(); // suppress text selection / native dragstart
});
content.addEventListener('mouseenter', () => {
if (!dragActive) return;
if (uid === dragStartUid) return;
dragMoved = true;
// First entry into another row → also apply mode to the start row
applyDragToRow(dragStartUid);
applyDragToRow(uid);
});
});
renderSelectionBar();
renderSpamToolbar();
}
function toggleSelect(uid, checked) {
if (checked) selectedUids.add(uid);
else selectedUids.delete(uid);
const row = document.querySelector(`.email-msg-row[data-uid="${uid}"]`);
row?.classList.toggle('selected', checked);
const cb = row?.querySelector('.email-msg-check');
if (cb) cb.checked = checked;
renderSelectionBar();
}
/** Apply current drag mode (add/remove) to a single row by UID. */
function applyDragToRow(uid) {
if (dragMode === 'add') {
if (selectedUids.has(uid)) return;
toggleSelect(uid, true);
} else {
if (!selectedUids.has(uid)) return;
toggleSelect(uid, false);
}
}
function renderSelectionBar() {
const bar = $('email-selection-bar');
if (!bar) return;
const n = selectedUids.size;
if (n === 0) {
bar.classList.add('hidden');
bar.classList.remove('flex');
} else {
bar.classList.remove('hidden');
bar.classList.add('flex');
$('email-selection-count').textContent = `${n} selected`;
}
}
// Selects both 'spam' and 'suspicious' — anything the heuristic flagged
function isFlaggedAsSpam(m) {
const lvl = m.spam?.level;
return lvl === 'spam' || lvl === 'suspicious';
}
function renderSpamToolbar() {
const btn = $('email-select-spam');
if (!btn) return;
const flaggedCount = currentMessages.filter(isFlaggedAsSpam).length;
if (flaggedCount === 0) {
btn.classList.add('hidden');
btn.classList.remove('flex');
} else {
btn.classList.remove('hidden');
btn.classList.add('flex');
$('email-spam-count').textContent = flaggedCount;
}
}
function selectAllSpam() {
selectedUids.clear();
for (const m of currentMessages) {
if (isFlaggedAsSpam(m)) selectedUids.add(m.uid);
}
// Re-render to apply checkbox + selected class
renderMessageList();
}
async function markSelectedAs(action) {
if (selectedUids.size === 0) return;
const n = selectedUids.size;
const verb = action === 'spam' ? 'as spam' : 'as not spam';
const ok = await hyprcrd.dialog.confirm(
`Mark ${n} sender${n === 1 ? '' : 's'} ${verb}? This adds ${action === 'spam' ? 'block' : 'allow'} rules that affect all future messages from the same senders.`,
{ title: `mark ${verb}`, okLabel: 'mark', cancelLabel: 'cancel', danger: action === 'spam' }
);
if (!ok) return;
$('email-status').textContent = `marking ${n}...`;
try {
const res = await hyprcrd.api('api/email/spam/mark', {
method: 'POST',
body: { account: currentAccountId, uids: [...selectedUids], action },
});
if (!res.ok) {
$('email-status').textContent = 'error: ' + (res.error || 'mark failed');
return;
}
$('email-status').textContent = `${res.added} rule${res.added === 1 ? '' : 's'} added`;
selectedUids.clear();
// Refresh so the new scoring is reflected immediately
await loadMessages();
} catch (err) {
$('email-status').textContent = 'error: ' + err.message;
}
}
function clearSelection() {
selectedUids.clear();
document.querySelectorAll('.email-msg-row.selected').forEach(r => r.classList.remove('selected'));
document.querySelectorAll('.email-msg-check').forEach(c => c.checked = false);
renderSelectionBar();
}
async function deleteSelected({ skipConfirm = false } = {}) {
if (selectedUids.size === 0) return;
const n = selectedUids.size;
if (!skipConfirm) {
const ok = await hyprcrd.dialog.confirm(
`Permanently delete ${n} message${n === 1 ? '' : 's'} from the server? This cannot be undone.`,
{ title: 'delete mail', okLabel: 'delete', cancelLabel: 'cancel', danger: true }
);
if (!ok) return;
}
const uidsToDelete = [...selectedUids];
$('email-status').textContent = `deleting ${n}...`;
try {
const res = await hyprcrd.api('api/email/delete', {
method: 'POST',
body: { account: currentAccountId, uids: uidsToDelete },
});
if (!res.ok) {
$('email-status').textContent = 'error: ' + (res.error || 'delete failed');
return;
}
// If the currently-open message was deleted, clear the body view
if (currentMessage && uidsToDelete.includes(currentMessage.uid)) {
currentMessage = null;
$('email-body').classList.add('hidden');
$('email-body-empty').classList.remove('hidden');
}
selectedUids.clear();
await loadMessages();
$('email-status').textContent = `deleted ${res.deleted}`;
} catch (err) {
$('email-status').textContent = 'error: ' + err.message;
}
}
async function openMessage(uid) {
document.querySelectorAll('.email-msg-row').forEach(r => r.classList.remove('active'));
document.querySelector(`.email-msg-row[data-uid="${uid}"]`)?.classList.add('active');
$('email-body').classList.add('hidden');
$('email-body-empty').classList.remove('hidden');
$('email-status').textContent = 'opening...';
try {
const res = await fetch(`api/email/message/${currentAccountId}/${uid}`);
const data = await res.json();
if (!data.ok) {
$('email-status').textContent = 'error: ' + (data.error || '');
return;
}
currentMessage = data;
$('email-msg-subject').textContent = data.subject || '(no subject)';
$('email-msg-from').textContent = data.from || '';
$('email-msg-date').textContent = data.date || '';
$('email-extract-status').textContent = '';
// Always start in text view; HTML view is opt-in per message
showMessageAsText();
// Toggle visibility of "view as html" button based on whether the
// message actually has an HTML part
const hasHtml = !!(data.html && data.html.trim());
const viewHtmlBtn = $('email-view-html');
viewHtmlBtn.classList.toggle('hidden', !hasHtml);
viewHtmlBtn.classList.toggle('inline-flex', hasHtml);
$('email-body-empty').classList.add('hidden');
$('email-body').classList.remove('hidden');
$('email-status').textContent = '';
} catch (err) {
$('email-status').textContent = 'error';
}
}
// ── HTML view toggle ──────────────────────────────────────────────
function showMessageAsText() {
$('email-msg-text').textContent = currentMessage?.text || '(empty body)';
$('email-msg-text').classList.remove('hidden');
$('email-msg-html').classList.add('hidden');
$('email-view-text').classList.add('hidden');
$('email-view-text').classList.remove('inline-flex');
$('email-load-images').classList.add('hidden');
$('email-load-images').classList.remove('inline-flex');
if (currentMessage?.html?.trim()) {
$('email-view-html').classList.remove('hidden');
$('email-view-html').classList.add('inline-flex');
}
}
/**
* Render the message HTML inside a sandboxed iframe.
* By default external images are blocked; pass loadImages=true to reveal them.
*/
function showMessageAsHtml(loadImages = false) {
if (!currentMessage?.html) return;
const iframe = $('email-msg-html');
// Sanitize first — DOMPurify is loaded globally by the core
let safeHtml = (typeof DOMPurify !== 'undefined')
? DOMPurify.sanitize(currentMessage.html, {
WHOLE_DOCUMENT: true,
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|cid):|#|\/|\.\.\/|\.\/)/i,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link'],
FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover'],
})
: currentMessage.html;
// Walk the parsed DOM to rewrite img src + count blocked
const wrapper = document.createElement('div');
wrapper.innerHTML = safeHtml;
let blockedCount = 0;
if (!loadImages) {
wrapper.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src') || '';
// Allow inline data: and cid: refs (cid won't actually load anyway)
if (src && !src.startsWith('data:') && !src.startsWith('cid:')) {
img.setAttribute('data-blocked-src', src);
img.removeAttribute('src');
img.setAttribute('alt', img.getAttribute('alt') || '[blocked image]');
img.style.cssText = 'display:inline-block;min-width:60px;min-height:30px;background:#eee;border:1px dashed #999;color:#666;padding:6px;font:10px sans-serif;text-align:center;';
blockedCount++;
}
});
// Also strip CSS background-image declarations from inline styles
wrapper.querySelectorAll('[style*="background"]').forEach(el => {
const s = el.getAttribute('style') || '';
if (/url\s*\(/i.test(s)) {
el.setAttribute('style', s.replace(/background[^;]*url\s*\([^)]*\)[^;]*;?/gi, ''));
blockedCount++;
}
});
}
safeHtml = wrapper.innerHTML;
// Wrap in a minimal HTML document with readable defaults
const doc = `<!doctype html><html><head><meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; line-height: 1.5; color: #222; padding: 14px; margin: 0; word-wrap: break-word; }
img { max-width: 100%; height: auto; }
a { color: #2563eb; }
table { border-collapse: collapse; max-width: 100%; }
td, th { padding: 4px 6px; }
blockquote { border-left: 3px solid #ccc; margin: 8px 0 8px 0; padding-left: 10px; color: #555; }
pre, code { font-family: ui-monospace, monospace; background: #f4f4f4; padding: 2px 4px; border-radius: 2px; }
* { max-width: 100%; box-sizing: border-box; }
</style>
</head><body>${safeHtml}</body></html>`;
iframe.srcdoc = doc;
iframe.classList.remove('hidden');
$('email-msg-text').classList.add('hidden');
$('email-view-html').classList.add('hidden');
$('email-view-html').classList.remove('inline-flex');
$('email-view-text').classList.remove('hidden');
$('email-view-text').classList.add('inline-flex');
const loadBtn = $('email-load-images');
if (blockedCount > 0 && !loadImages) {
$('email-blocked-count').textContent = blockedCount;
loadBtn.classList.remove('hidden');
loadBtn.classList.add('inline-flex');
} else {
loadBtn.classList.add('hidden');
loadBtn.classList.remove('inline-flex');
}
}
// ── Extract message → card ────────────────────────────────────────
async function extractMessage() {
if (!currentMessage) return;
const status = $('email-extract-status');
status.textContent = 'creating...';
status.style.color = 'var(--t-dim)';
const acc = accounts.find(a => a.id === currentAccountId);
const accLabel = acc ? acc.name : 'mail';
const title = currentMessage.subject || '(no subject)';
const body = [
`**From:** ${currentMessage.from || ''}`,
`**Date:** ${currentMessage.date || ''}`,
`**Account:** ${accLabel}`,
'',
'---',
'',
currentMessage.text || '',
'',
'#email',
].join('\n');
try {
const res = await hyprcrd.api('api/cards', {
method: 'POST',
body: { title, body, color: '#a78bfa' },
});
if (res.error) {
status.textContent = 'error: ' + res.error;
status.style.color = 'var(--t-danger)';
return;
}
status.textContent = 'card created';
status.style.color = 'var(--t-accent)';
window._reloadAll?.();
} catch (err) {
status.textContent = 'error: ' + err.message;
status.style.color = 'var(--t-danger)';
}
}
// ── Helpers ────────────────────────────────────────────────────────
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function extractName(from) {
const m = String(from).match(/^"?([^"<]+?)"?\s*<.+>$/);
return m ? m[1].trim() : from;
}
function formatDate(rawDate) {
if (!rawDate) return '';
const d = new Date(rawDate);
if (isNaN(d.getTime())) return rawDate.substring(0, 16);
const today = new Date();
if (d.toDateString() === today.toDateString()) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// ── Resizable divider ─────────────────────────────────────────────
function initDivider() {
const divider = $('email-divider');
const left = $('email-list-pane');
if (!divider || !left) return;
let dragging = false;
divider.addEventListener('mousedown', e => { dragging = true; e.preventDefault(); });
window.addEventListener('mousemove', e => {
if (!dragging) return;
const rect = $('email-view-mail').getBoundingClientRect();
const pct = ((e.clientX - rect.left) / rect.width) * 100;
if (pct > 15 && pct < 75) left.style.width = pct + '%';
});
window.addEventListener('mouseup', () => { dragging = false; });
}
// ── Header button injection ───────────────────────────────────────
function injectHeaderButton() {
const header = document.querySelector('#main-header .flex.items-center.gap-3:last-of-type');
if (!header) return;
if (document.getElementById('email-btn')) return;
const btn = document.createElement('button');
btn.id = 'email-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-envelope text-sm"></i><span class="font-pixel text-[10px]">mail</span>';
btn.addEventListener('click', openEmail);
const addBtn = document.getElementById('add-btn');
if (addBtn) header.insertBefore(btn, addBtn);
else header.appendChild(btn);
}
// ── Init ──────────────────────────────────────────────────────────
function init() {
if (!$('email-overlay')) return;
$('email-close').addEventListener('click', closeEmail);
$('email-overlay').addEventListener('click', e => {
if (e.target === $('email-overlay')) closeEmail();
});
$('email-overlay').addEventListener('keydown', e => {
if (e.key === 'Escape') { closeEmail(); e.stopPropagation(); }
});
$('email-account-select').addEventListener('change', e => {
currentAccountId = parseInt(e.target.value);
clearSelection();
currentOffset = 0;
if (currentAccountId) loadMessages();
});
$('email-refresh').addEventListener('click', () => { clearSelection(); loadMessages(); });
$('email-page-prev').addEventListener('click', gotoPrevPage);
$('email-page-next').addEventListener('click', gotoNextPage);
$('email-show-settings').addEventListener('click', gotoAccountsSettings);
$('email-welcome-configure').addEventListener('click', gotoAccountsSettings);
$('email-extract-btn').addEventListener('click', extractMessage);
$('email-view-html').addEventListener('click', () => showMessageAsHtml(false));
$('email-view-text').addEventListener('click', showMessageAsText);
$('email-load-images').addEventListener('click', () => showMessageAsHtml(true));
$('email-selection-delete').addEventListener('click', deleteSelected);
$('email-selection-clear').addEventListener('click', clearSelection);
$('email-selection-mark-spam').addEventListener('click', () => markSelectedAs('spam'));
$('email-selection-mark-ham').addEventListener('click', () => markSelectedAs('ham'));
$('email-select-spam').addEventListener('click', selectAllSpam);
initDivider();
injectHeaderButton();
// Backspace deletes selected mails (Shift+Backspace skips confirm)
document.addEventListener('keydown', e => {
if (e.key !== 'Backspace') return;
if (!$('email-overlay')?.classList.contains('open')) return;
if (selectedUids.size === 0) return;
// Don't hijack while user is typing in a text-like input.
// Checkboxes, radios, buttons etc. should NOT block backspace —
// a focused row checkbox is the most common state when bulk-deleting.
const ae = document.activeElement;
if (ae) {
if (ae.isContentEditable) return;
if (ae.tagName === 'TEXTAREA') return;
if (ae.tagName === 'INPUT') {
const t = (ae.type || 'text').toLowerCase();
const textTypes = ['text', 'password', 'email', 'search', 'url', 'tel', 'number'];
if (textTypes.includes(t)) return;
}
}
e.preventDefault();
deleteSelected({ skipConfirm: e.shiftKey });
});
// Drag-select / click resolver: ends drag, decides click semantics
window.addEventListener('mouseup', () => {
if (!dragActive) return;
const startUid = dragStartUid;
const wasMoved = dragMoved;
dragActive = false;
dragStartUid = null;
dragMoved = false;
if (wasMoved) return; // drag-select already mutated state via mouseenter
// Pure click on a row body
if (selectedUids.size > 0) {
// In selection mode → toggle this row in/out of selection
toggleSelect(startUid, !selectedUids.has(startUid));
} else {
// Nothing selected → open the message
openMessage(startUid);
}
});
// Register the "mail" tab inside the core settings overlay
hyprcrd.settings.registerTab({
id: 'email',
label: 'mail',
icon: 'ph-envelope',
onShow: (panelEl) => refreshSettingsPanel(panelEl),
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
hyprcrd.registerPlugin({
name: 'email',
version: '1.0.0',
description: 'IMAP mail viewer with card extraction',
});
+11
View File
@@ -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"
}
+3 -2
View File
@@ -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);
}
+27
View File
@@ -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 = [];