From cc97754ae66b115b0c49d8c097afe2e6b9fa9b79 Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 10 Apr 2026 10:42:18 +0200
Subject: [PATCH] email plugin
---
gitea-issue-client | 1 +
public/index.php | 4 +
public/js/app.js | 7 +-
public/js/hyprcrd.js | 103 +++
public/js/modules/dialog.js | 5 +
public/plugins/MINDMAP.md | 294 ++++++++
public/plugins/email/ImapClient.php | 472 +++++++++++++
public/plugins/email/SpamDetector.php | 332 +++++++++
public/plugins/email/TODO.md | 109 +++
public/plugins/email/backend.php | 377 ++++++++++
public/plugins/email/email.css | 172 +++++
public/plugins/email/email.html | 106 +++
public/plugins/email/email.js | 959 ++++++++++++++++++++++++++
public/plugins/email/plugin.json | 11 +
public/plugins/tests/settings.test.js | 5 +-
src/PluginManager.php | 27 +
16 files changed, 2979 insertions(+), 5 deletions(-)
create mode 160000 gitea-issue-client
create mode 100644 public/plugins/MINDMAP.md
create mode 100644 public/plugins/email/ImapClient.php
create mode 100644 public/plugins/email/SpamDetector.php
create mode 100644 public/plugins/email/TODO.md
create mode 100644 public/plugins/email/backend.php
create mode 100644 public/plugins/email/email.css
create mode 100644 public/plugins/email/email.html
create mode 100644 public/plugins/email/email.js
create mode 100644 public/plugins/email/plugin.json
diff --git a/gitea-issue-client b/gitea-issue-client
new file mode 160000
index 0000000..3d0c2f3
--- /dev/null
+++ b/gitea-issue-client
@@ -0,0 +1 @@
+Subproject commit 3d0c2f3a82495df44e5697569694b4f21c4f6853
diff --git a/public/index.php b/public/index.php
index 954bd80..cf52cfb 100644
--- a/public/index.php
+++ b/public/index.php
@@ -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);
diff --git a/public/js/app.js b/public/js/app.js
index 577b3f0..a7752b9 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -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 */ }
}
}
diff --git a/public/js/hyprcrd.js b/public/js/hyprcrd.js
index c2fbc3f..05e96ea 100644
--- a/public/js/hyprcrd.js
+++ b/public/js/hyprcrd.js
@@ -98,6 +98,7 @@
};
// ── Util helpers (mirror of internal util.js) ───────────────────
+ let csrfToken = '';
const util = {
$(idOrSel) {
if (typeof idOrSel !== 'string') return null;
@@ -114,6 +115,105 @@
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
},
+ // CSRF: core calls setCsrf() after auth so plugins can read it
+ setCsrf(token) { csrfToken = String(token || ''); },
+ csrf() { return csrfToken; },
+ };
+
+ /** Generic JSON API call with automatic CSRF header. */
+ async function api(path, opts = {}) {
+ const headers = { 'Content-Type': 'application/json' };
+ if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
+ const res = await fetch(path, {
+ method: opts.method || 'GET',
+ headers,
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
+ });
+ return res.json();
+ }
+
+ // ── Settings registry ───────────────────────────────────────────
+ // Plugins can inject their own tabs into the main settings overlay.
+ // The plugin's onShow callback is called whenever the tab becomes visible.
+ const settings = {
+ registerTab({ id, label, icon, onShow }) {
+ if (!id) return;
+ const nav = document.getElementById('settings-nav');
+ const panelsContainer = document.querySelector('#settings-overlay .overflow-y-auto.p-5');
+ if (!nav || !panelsContainer) {
+ // DOM not ready — defer to next tick
+ requestAnimationFrame(() => settings.registerTab({ id, label, icon, onShow }));
+ return;
+ }
+ if (nav.querySelector(`[data-tab="${id}"]`)) return; // already registered
+
+ // Create tab button
+ const btn = document.createElement('button');
+ btn.className = 'settings-tab';
+ btn.dataset.tab = id;
+ const iconClass = icon || 'ph-puzzle-piece';
+ btn.innerHTML = `${label || id}`;
+ 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 — set by core dialog.js
+ /** Convenience confirm wrapper. Returns Promise. */
+ 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',
diff --git a/public/js/modules/dialog.js b/public/js/modules/dialog.js
index 24ff9a6..3fc3f65 100644
--- a/public/js/modules/dialog.js
+++ b/public/js/modules/dialog.js
@@ -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); });
diff --git a/public/plugins/MINDMAP.md b/public/plugins/MINDMAP.md
new file mode 100644
index 0000000..8484ed1
--- /dev/null
+++ b/public/plugins/MINDMAP.md
@@ -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
+│ ├── Then plugin entries: = PluginManager::instance()->scriptTags() ?>
+│ ├── Then core ES module: |