378 lines
16 KiB
PHP
378 lines
16 KiB
PHP
<?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);
|
|
}
|
|
}
|
|
});
|