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

+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);
}
}
});