email plugin
This commit is contained in:
1 parent
3f4ba3c0f4
commit
cc97754ae6
16 files changed
+2979
-5
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user