From 7c1cfda588f40c4cd729a92cb467b999145d38f2 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 31 May 2026 18:57:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20email=20intelligence=20=E2=80=94=20acti?= =?UTF-8?q?on=20item=20detection,=20task/appt=20creation,=20admin=20EMAIL?= =?UTF-8?q?=20tab,=20full=20voice=20intents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/endpoints/chat.php | 155 ++++++++++++++-------- api/endpoints/email.php | 247 +++++++++++++++++++++++++----------- public_html/admin/index.php | 149 ++++++++++++++++++++++ 3 files changed, 423 insertions(+), 128 deletions(-) diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php index a6670f3..98cb031 100644 --- a/api/endpoints/chat.php +++ b/api/endpoints/chat.php @@ -300,80 +300,127 @@ if (!$reply && preg_match('/(is|are|what.s|status|state).*(on|off|light|switch|p } -// ── Email queries ───────────────────────────────────────────────────────── +// ── Email + Planner voice intents (action items, create task/appt from email) ─ if (!$reply && preg_match('/\b(email|emails|inbox|gmail|outlook|mail|unread|messages)\b/i', $message)) { - $emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email'; - $account = 'all'; - if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail'; - if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook'; - if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud'; + $lc = strtolower($message); - $ch = curl_init($emailUrl . '?account=' . $account); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ['X-Session-Token: ' . ($_SESSION['jarvis_token'] ?? '')], - CURLOPT_TIMEOUT => 20, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_SSL_VERIFYPEER => false, - ]); - $emailJson = curl_exec($ch); - $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + // ── Action items from email ─────────────────────────────────────────────── + if (preg_match('/\b(action item|action required|need.*attention|follow.?up|things to do.*email|email.*task)\b/i', $message)) { + $rows = JarvisDB::query( + "SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 5" + ) ?? []; + if (!$rows) { + $reply = "No email action items pending, {$userAddr}."; + } else { + $items = array_map(fn($r) => + ucfirst($r['action_type']) . ': "' . mb_substr($r['subject'], 0, 60) . '" from ' . ($r['from_name'] ?: $r['from_email']), + $rows + ); + $reply = count($rows) . " email action item" . (count($rows)>1?'s':'') . " need attention, {$userAddr}: " . implode('. ', $items) . '.'; + } + $source = 'email:action_items'; - if ($emailCode === 200 && $emailJson) { - $ed = json_decode($emailJson, true) ?? []; - $summary = $ed['summary'] ?? []; - $unread = (int)($summary['total_unread'] ?? 0); - $recent = $summary['recent'] ?? []; - $accts = $ed['accounts'] ?? []; + // ── Create task from most recent action email ───────────────────────────── + } elseif (preg_match('/\b(create task|add task|make.*task|task.*from.*email)\b/i', $message)) { + $fromMatch = null; + if (preg_match('/\bfrom\s+([a-z][\w\s\.]+)/i', $message, $fm)) $fromMatch = trim($fm[1]); + $where = "WHERE dismissed=0 AND task_id IS NULL AND action_type IN ('task','appointment')"; + $params = []; + if ($fromMatch) { $where .= ' AND (from_name LIKE ? OR from_email LIKE ?)'; $params[] = "%{$fromMatch}%"; $params[] = "%{$fromMatch}%"; } + $ea = JarvisDB::single("SELECT * FROM email_actions {$where} ORDER BY received_at DESC LIMIT 1", $params); + if ($ea) { + $title = mb_substr($ea['subject'], 0, 255); + $notes = "From: {$ea['from_name']} <{$ea['from_email']}>"; + $taskId = JarvisDB::insert('INSERT INTO tasks (title,notes,category,priority) VALUES (?,?,?,?)', + [$title, $notes, 'work', 'normal']); + JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?', [$taskId, $ea['id']]); + $reply = "Task created from email: \"{$title}\", {$userAddr}."; + $source = 'email:create_task'; + } else { + $reply = "No action-required emails found to create a task from, {$userAddr}."; + $source = 'email:create_task_none'; + } - // "How many" / "any" / check → just count - if (preg_match('/\b(how many|any|check|count|unread)\b/i', $message) && !preg_match('/\bread\b/i', $message)) { + // ── Create appointment from meeting email ───────────────────────────────── + } elseif (preg_match('/\b(schedule|appointment|meeting|calendar).*\bemail\b|\bemail\b.*(schedule|appointment|meeting|calendar)\b/i', $message)) { + $ea = JarvisDB::single( + "SELECT * FROM email_actions WHERE dismissed=0 AND appointment_id IS NULL AND action_type='appointment' ORDER BY received_at DESC LIMIT 1" + ); + if ($ea) { + $start = $ea['suggested_date'] ? $ea['suggested_date'] . ' 09:00:00' : date('Y-m-d') . ' 09:00:00'; + $title = mb_substr($ea['subject'], 0, 255); + $apptId = JarvisDB::insert('INSERT INTO appointments (title,description,category,start_at) VALUES (?,?,?,?)', + [$title, "From: {$ea['from_name']}", 'work', $start]); + JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?', [$apptId, $ea['id']]); + $reply = "Appointment scheduled from email: \"{$title}\" on " . date('l, M j', strtotime($start)) . ", {$userAddr}."; + $source = 'email:create_appt'; + } else { + $reply = "No meeting emails found to schedule, {$userAddr}."; + $source = 'email:create_appt_none'; + } + + // ── Unread count ────────────────────────────────────────────────────────── + } elseif (preg_match('/\b(how many|any|check|count|unread)\b/i', $message) && !preg_match('/\bread\b/i', $message)) { + $emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email'; + $account = 'all'; + if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail'; + if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook'; + if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud'; + $ch = curl_init($emailUrl . '?account=' . $account); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>['X-Session-Token: '.($_SESSION['jarvis_token']??'')], CURLOPT_TIMEOUT=>20, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>false]); + $emailJson = curl_exec($ch); $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + if ($emailCode === 200 && $emailJson) { + $ed = json_decode($emailJson, true) ?? []; + $unread = (int)($ed['summary']['total_unread'] ?? 0); + $aiCount = (int)($ed['action_items_count'] ?? 0); + $parts = []; + foreach ($ed['accounts'] ?? [] as $a => $r) { + if (!empty($r['unread'])) $parts[] = $r['unread'] . ' on ' . ucfirst($a); + } if ($unread === 0) { $reply = "No unread emails, {$userAddr}."; } else { - // break down by account - $parts = []; - foreach ($accts as $a => $r) { - if (!empty($r['unread'])) $parts[] = $r['unread'] . ' on ' . ucfirst($a); - } - $breakdown = $parts ? ' (' . implode(', ', $parts) . ')' : ''; - $reply = "You have {$unread} unread email" . ($unread>1?'s':'') . "{$breakdown}, {$userAddr}."; + $bd = $parts ? ' (' . implode(', ', $parts) . ')' : ''; + $reply = "You have {$unread} unread email" . ($unread>1?'s':'') . "{$bd}, {$userAddr}."; } + if ($aiCount > 0) $reply .= " {$aiCount} require action."; $source = 'email:count'; + } - // "emails from [person]" → filter by sender - } elseif (preg_match('/\bfrom\s+(.+)/i', $message, $fm)) { - $sender = strtolower(trim($fm[1])); - $matches = array_filter($recent, fn($m) => - stripos($m['from_name']??'', $sender) !== false || - stripos($m['from_email']??'', $sender) !== false - ); - if ($matches) { - $m = array_values($matches)[0]; - $reply = "Email from {$m['from_name']}: \"{$m['subject']}\" — {$m['date']}."; - if (!empty($m['preview'])) $reply .= ' Preview: ' . mb_substr($m['preview'], 0, 150); - } else { - $reply = "No recent emails from {$sender}, {$userAddr}."; + // ── Read recent emails ──────────────────────────────────────────────────── + } else { + $emailUrl = (defined('SITE_URL') ? SITE_URL : 'https://jarvis.orbishosting.com') . '/api/email'; + $account = 'all'; + if (preg_match('/\bgmail\b/i', $message)) $account = 'gmail'; + if (preg_match('/\boutlook\b/i', $message)) $account = 'outlook'; + if (preg_match('/\bicloud\b/i', $message)) $account = 'icloud'; + $ch = curl_init($emailUrl . '?account=' . $account); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_HTTPHEADER=>['X-Session-Token: '.($_SESSION['jarvis_token']??'')], CURLOPT_TIMEOUT=>20, CURLOPT_CONNECTTIMEOUT=>5, CURLOPT_SSL_VERIFYPEER=>false]); + $emailJson = curl_exec($ch); $emailCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + if ($emailCode === 200 && $emailJson) { + $ed = json_decode($emailJson, true) ?? []; + $recent = $ed['summary']['recent'] ?? []; + $unread = (int)($ed['summary']['total_unread'] ?? 0); + $aiCount = (int)($ed['action_items_count'] ?? 0); + // filter by sender if mentioned + if (preg_match('/\bfrom\s+(.+)/i', $message, $fm)) { + $sender = strtolower(trim($fm[1])); + $recent = array_values(array_filter($recent, fn($m) => + stripos($m['from_name']??'', $sender)!==false || stripos($m['from_email']??'', $sender)!==false)); } - $source = 'email:search'; - - // "read" / "latest" / "recent" → read top emails - } else { - $unreadOnly = array_values(array_filter($recent, fn($m) => $m['unread'])); - $toRead = $unreadOnly ?: $recent; - $toRead = array_slice($toRead, 0, 3); + $toRead = array_filter($recent, fn($m) => $m['unread']) ?: $recent; + $toRead = array_slice(array_values($toRead), 0, 3); if (empty($toRead)) { $reply = "No emails to report, {$userAddr}."; } else { $lines = []; foreach ($toRead as $m) { - $flag = $m['unread'] ? '' : ''; - $acct = isset($m['account']) ? ' [' . strtoupper($m['account']) . ']' : ''; + $acct = isset($m['account']) ? ' [' . strtoupper($m['account']) . ']' : ''; $lines[] = "From {$m['from_name']}: \"{$m['subject']}\", {$m['date']}{$acct}."; } $intro = $unread > 0 ? "You have {$unread} unread. " : ""; $reply = $intro . implode(' ', $lines); + if ($aiCount > 0) $reply .= " {$aiCount} require action — say 'email action items' for details."; } $source = 'email:read'; } diff --git a/api/endpoints/email.php b/api/endpoints/email.php index d752948..6bababc 100644 --- a/api/endpoints/email.php +++ b/api/endpoints/email.php @@ -1,139 +1,238 @@ 'not_configured']; - $mbox = @imap_open($host, $user, $pass, 0, 1, ['DISABLE_AUTHENTICATOR' => 'GSSAPI']); if (!$mbox) return ['error' => imap_last_error() ?: 'connection_failed']; - - $total = imap_num_msg($mbox); - $unseen = imap_search($mbox, 'UNSEEN') ?: []; - $unread = count($unseen); - - // Fetch most recent messages (newest first) - $start = max(1, $total - $maxMsgs + 1); - $msgs = []; - for ($i = $total; $i >= $start; $i--) { - $hdr = @imap_headerinfo($mbox, $i); + $total = imap_num_msg($mbox); + $unseen = imap_search($mbox, 'UNSEEN') ?: []; + $unread = count($unseen); + $msgs = []; + for ($i = $total; $i >= max(1, $total - $maxMsgs + 1); $i--) { + $hdr = @imap_headerinfo($mbox, $i); if (!$hdr) continue; - - $from = $hdr->from[0] ?? null; - $fromName = $from ? (isset($from->personal) ? imap_utf8($from->personal) : '') : ''; + $from = $hdr->from[0] ?? null; + $fromName = $from && isset($from->personal) ? imap_utf8($from->personal) : ''; $fromEmail = $from ? ($from->mailbox . '@' . ($from->host ?? '')) : ''; $subject = isset($hdr->subject) ? imap_utf8($hdr->subject) : '(no subject)'; - $date = isset($hdr->date) ? date('M j g:ia', strtotime($hdr->date)) : ''; + $date = isset($hdr->date) ? date('M j g:ia', strtotime($hdr->date)) : ''; + $dateRaw = isset($hdr->date) ? date('Y-m-d H:i:s', strtotime($hdr->date)) : null; $isUnread = in_array($i, $unseen); - - // Fetch plain text preview (first 300 chars) + $msgId = md5(($hdr->message_id ?? '') ?: ($fromEmail . $subject . $date)); + // Preview $preview = ''; $struct = @imap_fetchstructure($mbox, $i); if ($struct) { if ($struct->type === 0) { - // Single-part message $raw = @imap_fetchbody($mbox, $i, '1'); $enc = $struct->encoding ?? 0; - if ($enc === 3) $raw = base64_decode($raw); - elseif ($enc === 4) $raw = quoted_printable_decode($raw); - $preview = mb_substr(strip_tags($raw), 0, 300); + if ($enc === 3) $raw = base64_decode($raw); + elseif ($enc === 4) $raw = quoted_printable_decode($raw); + $preview = mb_substr(strip_tags($raw), 0, 400); } else { - // Multi-part — find first text/plain part foreach (($struct->parts ?? []) as $idx => $part) { - if ($part->type === 0) { // text + if ($part->type === 0) { $raw = @imap_fetchbody($mbox, $i, (string)($idx + 1)); $enc = $part->encoding ?? 0; - if ($enc === 3) $raw = base64_decode($raw); - elseif ($enc === 4) $raw = quoted_printable_decode($raw); - $preview = mb_substr(strip_tags($raw), 0, 300); + if ($enc === 3) $raw = base64_decode($raw); + elseif ($enc === 4) $raw = quoted_printable_decode($raw); + $preview = mb_substr(strip_tags($raw), 0, 400); break; } } } } $preview = trim(preg_replace('/\s+/', ' ', $preview)); - $msgs[] = [ - 'id' => $i, - 'from_name' => $fromName ?: $fromEmail, - 'from_email'=> $fromEmail, - 'subject' => $subject, - 'date' => $date, - 'unread' => $isUnread, - 'preview' => $preview, + 'id' => $i, + 'msg_id' => $msgId, + 'from_name' => $fromName ?: $fromEmail, + 'from_email' => $fromEmail, + 'subject' => $subject, + 'date' => $date, + 'date_raw' => $dateRaw, + 'unread' => $isUnread, + 'preview' => $preview, ]; } - imap_close($mbox); return ['total' => $total, 'unread' => $unread, 'messages' => $msgs]; } +// ── Action item detection ───────────────────────────────────────────────────── +function detectActionItem(string $subject, string $preview): array { + $text = strtolower($subject . ' ' . mb_substr($preview, 0, 500)); + $apptKw = ['meeting','zoom call','teams call','video call','conference call','join us','calendar invite', + 'appointment','interview','webinar','you are invited','let\'s meet','lets meet', + 'scheduled for','scheduled at','set up a call','book a call','at \d{1,2}(:\d{2})?\s*(am|pm)']; + $taskKw = ['action required','action needed','please review','please respond','please confirm', + 'please send','please provide','please complete','please sign','please approve', + 'follow up','follow-up','reminder:','deadline','due by','due date', + 'asap','urgent','your attention','needs your','waiting for you', + 'can you','could you','would you please','i need you to','kindly']; + $apptScore = 0; + $taskScore = 0; + foreach ($apptKw as $kw) { + if (@preg_match('/' . $kw . '/i', $text)) { $apptScore += 25; break; } + } + foreach ($taskKw as $kw) { + if (strpos($text, $kw) !== false) { $taskScore += 20; break; } + } + // Bonus: explicit deadline date in subject + if (preg_match('/\b(by\s+)?(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|\d{1,2}\/\d{1,2})\b/i', $subject)) { + $taskScore += 15; + } + // Suggested date extraction + $sugDate = null; + if (preg_match('/\b(tomorrow|today|next\s+\w+|this\s+(monday|tuesday|wednesday|thursday|friday)|monday|tuesday|wednesday|thursday|friday|\d{1,2}[\/\-]\d{1,2}(?:[\/\-]\d{2,4})?)\b/i', $subject . ' ' . mb_substr($preview, 0, 200), $dm)) { + $ts = strtotime($dm[0]); + if ($ts !== false && $ts > time() - 86400) $sugDate = date('Y-m-d', $ts); + } + $type = null; + if ($apptScore >= 25 && $apptScore >= $taskScore) $type = 'appointment'; + elseif ($taskScore >= 20) $type = 'task'; + return ['type' => $type, 'suggested_date' => $sugDate]; +} + +// ── Upsert email actions ────────────────────────────────────────────────────── +function storeEmailActions(string $account, array $messages): void { + foreach ($messages as $msg) { + $ai = detectActionItem($msg['subject'], $msg['preview'] ?? ''); + if (!$ai['type']) continue; + JarvisDB::execute( + "INSERT INTO email_actions (account,message_uid,from_email,from_name,subject,preview,received_at,action_type,suggested_title,suggested_date) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE action_type=VALUES(action_type), suggested_title=VALUES(suggested_title), + suggested_date=VALUES(suggested_date), from_name=VALUES(from_name), + preview=VALUES(preview), received_at=VALUES(received_at)", + [$account, $msg['msg_id'], $msg['from_email'], $msg['from_name'], $msg['subject'], + mb_substr($msg['preview'] ?? '', 0, 500), $msg['date_raw'], + $ai['type'], mb_substr($msg['subject'], 0, 255), $ai['suggested_date']] + ); + } +} + +// ── Route ───────────────────────────────────────────────────────────────────── +$actionType = $data['action'] ?? $_GET['action'] ?? ''; + +// ── Create task from email action ───────────────────────────────────────────── +if ($method === 'POST' && $actionType === 'create_task') { + $id = (int)($data['id'] ?? 0); + if (!$id) { echo json_encode(['error' => 'Missing id']); exit; } + $ea = JarvisDB::single('SELECT * FROM email_actions WHERE id=?', [$id]); + if (!$ea) { echo json_encode(['error' => 'Not found']); exit; } + $title = trim($data['title'] ?? $ea['suggested_title']); + $due_date = trim($data['due_date'] ?? $ea['suggested_date'] ?? ''); + $notes = "From: {$ea['from_name']} <{$ea['from_email']}>\nSubject: {$ea['subject']}"; + $taskId = JarvisDB::insert( + 'INSERT INTO tasks (title,notes,category,priority,due_date) VALUES (?,?,?,?,?)', + [$title, $notes, 'work', 'normal', $due_date ?: null] + ); + JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?', [$taskId, $id]); + echo json_encode(['success' => true, 'task_id' => $taskId]); + exit; +} + +// ── Create appointment from email action ────────────────────────────────────── +if ($method === 'POST' && $actionType === 'create_appt') { + $id = (int)($data['id'] ?? 0); + if (!$id) { echo json_encode(['error' => 'Missing id']); exit; } + $ea = JarvisDB::single('SELECT * FROM email_actions WHERE id=?', [$id]); + if (!$ea) { echo json_encode(['error' => 'Not found']); exit; } + $title = trim($data['title'] ?? $ea['suggested_title']); + $start_at = trim($data['start_at'] ?? ''); + if (!$start_at && $ea['suggested_date']) $start_at = $ea['suggested_date'] . ' 09:00:00'; + if (!$start_at) $start_at = date('Y-m-d') . ' 09:00:00'; + $desc = "From: {$ea['from_name']} <{$ea['from_email']}>"; + $apptId = JarvisDB::insert( + 'INSERT INTO appointments (title,description,category,start_at) VALUES (?,?,?,?)', + [$title, $desc, 'work', $start_at] + ); + JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?', [$apptId, $id]); + echo json_encode(['success' => true, 'appointment_id' => $apptId]); + exit; +} + +// ── Dismiss email action ────────────────────────────────────────────────────── +if ($method === 'POST' && $actionType === 'dismiss') { + $id = (int)($data['id'] ?? 0); + if ($id) JarvisDB::execute('UPDATE email_actions SET dismissed=1 WHERE id=?', [$id]); + echo json_encode(['success' => true]); + exit; +} + +// ── List detected action items ──────────────────────────────────────────────── +if ($actionType === 'action_items') { + $rows = JarvisDB::query( + "SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL + ORDER BY received_at DESC LIMIT 50" + ) ?? []; + echo json_encode(['action_items' => $rows, 'count' => count($rows)]); + exit; +} + +// ── Inbox fetch (main) ──────────────────────────────────────────────────────── $account = $data['account'] ?? $_GET['account'] ?? 'all'; $force = !empty($data['force']) || !empty($_GET['force']); -$search = trim($data['search'] ?? $_GET['search'] ?? ''); - $cacheKey = 'email_' . $account; -$cacheTtl = 300; // 5 minutes +$cacheTtl = 300; if (!$force) { - $cached = JarvisDB::single( - "SELECT data, UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key=?", - [$cacheKey] - ); + $cached = JarvisDB::single("SELECT data, UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key=?", [$cacheKey]); if ($cached && (time() - (int)$cached['ts']) < $cacheTtl) { $out = json_decode($cached['data'], true); $out['cache_age_s'] = time() - (int)$cached['ts']; + // Merge unactioned email_actions count + $out['action_items_count'] = (int)(JarvisDB::single("SELECT COUNT(*) c FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL")['c'] ?? 0); echo json_encode($out); exit; } } -$result = ['accounts' => [], 'summary' => []]; +$result = ['accounts' => []]; -if (in_array($account, ['all', 'gmail']) && defined('GMAIL_USER') && GMAIL_USER) { - $r = imapFetch('{imap.gmail.com:993/imap/ssl}INBOX', GMAIL_USER, GMAIL_PASS); - $result['accounts']['gmail'] = $r; +$accounts_to_fetch = []; +if (in_array($account, ['all', 'gmail']) && defined('GMAIL_USER') && GMAIL_USER) $accounts_to_fetch['gmail'] = ['{imap.gmail.com:993/imap/ssl}INBOX', GMAIL_USER, GMAIL_PASS]; +if (in_array($account, ['all', 'outlook']) && defined('OUTLOOK_USER') && OUTLOOK_USER) $accounts_to_fetch['outlook'] = ['{outlook.office365.com:993/imap/ssl}INBOX', OUTLOOK_USER, OUTLOOK_PASS]; +if (in_array($account, ['all', 'icloud']) && defined('ICLOUD_USER') && ICLOUD_USER) $accounts_to_fetch['icloud'] = ['{imap.mail.me.com:993/imap/ssl}INBOX', ICLOUD_USER, ICLOUD_PASS]; + +foreach ($accounts_to_fetch as $acct => [$host, $user, $pass]) { + $r = imapFetch($host, $user, $pass, 15); + $result['accounts'][$acct] = $r; + if (!empty($r['messages'])) storeEmailActions($acct, $r['messages']); } -if (in_array($account, ['all', 'outlook']) && defined('OUTLOOK_USER') && OUTLOOK_USER) { - $r = imapFetch('{outlook.office365.com:993/imap/ssl}INBOX', OUTLOOK_USER, OUTLOOK_PASS); - $result['accounts']['outlook'] = $r; -} - -if (in_array($account, ['all', 'icloud']) && defined('ICLOUD_USER') && ICLOUD_USER) { - $r = imapFetch('{imap.mail.me.com:993/imap/ssl}INBOX', ICLOUD_USER, ICLOUD_PASS); - $result['accounts']['icloud'] = $r; -} - -// Build summary across all accounts +// Build summary $totalUnread = 0; $allMessages = []; foreach ($result['accounts'] as $acct => $r) { if (isset($r['unread'])) $totalUnread += (int)$r['unread']; - if (!empty($r['messages'])) { - foreach ($r['messages'] as $m) { - $m['account'] = $acct; - $allMessages[] = $m; - } + foreach ($r['messages'] ?? [] as $m) { + $m['account'] = $acct; + $allMessages[] = $m; } } +// Sort by date descending +usort($allMessages, fn($a,$b) => strtotime($b['date_raw']??'0') - strtotime($a['date_raw']??'0')); -// Sort by date descending (approximate — already newest first per account) $result['summary'] = [ 'total_unread' => $totalUnread, - 'recent' => array_slice($allMessages, 0, 10), + 'recent' => array_slice($allMessages, 0, 15), 'fetched_at' => date('c'), ]; +$result['action_items_count'] = (int)(JarvisDB::single("SELECT COUNT(*) c FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL")['c'] ?? 0); -// Cache result JarvisDB::execute( - "INSERT INTO api_cache (cache_key, data, updated_at) VALUES (?,?,NOW()) - ON DUPLICATE KEY UPDATE data=VALUES(data), updated_at=NOW()", + "INSERT INTO api_cache (cache_key,data,updated_at) VALUES (?,?,NOW()) ON DUPLICATE KEY UPDATE data=VALUES(data),updated_at=NOW()", [$cacheKey, json_encode($result)] ); - $result['cache_age_s'] = 0; echo json_encode($result); diff --git a/public_html/admin/index.php b/public_html/admin/index.php index c3d462c..7b7a97b 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -320,6 +320,49 @@ if ($action) { j(['vms'=>$pve['vms']??[],'containers'=>$pve['containers']??[],'node_info'=>$pve['node_info']??[],'node_status'=>$pve['node_status']??null,'ts'=>$raw['ts']]); // ── USERS ──────────────────────────────────────────────────────────── + case 'email_inbox': + // Call via server's own IP — REMOTE_ADDR matches JARVIS_IP so auth bypass applies + $acct = $_GET['account'] ?? 'all'; + $force = !empty($_GET['force']) ? '&force=1' : ''; + $ch = curl_init('https://165.22.1.228/api/email?account=' . $acct . $force); + curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>25, + CURLOPT_SSL_VERIFYPEER=>false,CURLOPT_SSL_VERIFYHOST=>false, + CURLOPT_HTTPHEADER=>['Host: jarvis.orbishosting.com']]); + $r = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); + if($code===200 && $r) j(json_decode($r,true)); + else j(['error'=>'Email fetch failed (HTTP '.$code.')']); + + case 'email_action_items': + $rows = JarvisDB::query("SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 100") ?? []; + j(['action_items'=>$rows]); + + case 'email_create_task': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + $ea=JarvisDB::single('SELECT * FROM email_actions WHERE id=?',[$id]); if(!$ea) bad('Not found'); + $title=trim($_POST['title']??$ea['suggested_title']); + $due=trim($_POST['due_date']??$ea['suggested_date']??''); + $notes="From: {$ea['from_name']} <{$ea['from_email']}>\nSubject: {$ea['subject']}"; + $tid=JarvisDB::insert('INSERT INTO tasks(title,notes,category,priority,due_date)VALUES(?,?,?,?,?)', + [$title,$notes,'work','normal',$due?:null]); + JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?',[$tid,$id]); + j(['ok'=>true,'task_id'=>$tid]); + + case 'email_create_appt': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + $ea=JarvisDB::single('SELECT * FROM email_actions WHERE id=?',[$id]); if(!$ea) bad('Not found'); + $title=trim($_POST['title']??$ea['suggested_title']); + $start=trim($_POST['start_at']??''); + if(!$start) $start=($ea['suggested_date']??date('Y-m-d')).' 09:00:00'; + $aid=JarvisDB::insert('INSERT INTO appointments(title,description,category,start_at)VALUES(?,?,?,?)', + [$title,"From: {$ea['from_name']} <{$ea['from_email']}>",'work',$start]); + JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?',[$aid,$id]); + j(['ok'=>true,'appointment_id'=>$aid]); + + case 'email_dismiss': + $id=(int)($_POST['id']??0); + if($id) JarvisDB::execute('UPDATE email_actions SET dismissed=1 WHERE id=?',[$id]); + j(['ok'=>true]); + case 'task_list': $status = trim($_GET['status'] ?? ''); $category = trim($_GET['category'] ?? ''); @@ -612,6 +655,8 @@ select.filter-sel:focus{border-color:var(--cyan)} + + @@ -772,6 +817,29 @@ select.filter-sel:focus{border-color:var(--cyan)}
SCANNING...
+ +
+
EMAIL INTELLIGENCE +
+ + + + +
+
+
+
+
+ +
+
TASKS @@ -919,6 +987,7 @@ function loadTab(tab) { vms: loadVMs, sites: loadSites, users: loadUsers, + email: ()=>{ loadEmailInbox(); loadEmailActionItems(); }, tasks: loadTasks, appointments: loadAppts, })[tab]?.(); @@ -1658,6 +1727,86 @@ document.getElementById('app').style.display='flex'; document.getElementById('adminUser').textContent = ''.toUpperCase(); initApp(); +// ── EMAIL ─────────────────────────────────────────────────────────────────── +let _emailCurrentTab = 'inbox'; + +function emailShowTab(tab) { + _emailCurrentTab = tab; + document.getElementById('email-inbox-view').style.display = tab==='inbox' ? '' : 'none'; + document.getElementById('email-actions-view').style.display = tab==='actions' ? '' : 'none'; + document.getElementById('email-tab-inbox').style.background = tab==='inbox' ? 'rgba(0,212,255,0.15)' : ''; + document.getElementById('email-tab-actions').style.background = tab==='actions' ? 'rgba(0,212,255,0.15)' : ''; + if (tab === 'actions') loadEmailActionItems(); + else loadEmailInbox(); +} + +async function loadEmailInbox(force=false) { + const acct = document.getElementById('email-acct-filter')?.value || 'all'; + const el = document.getElementById('email-tbl'); + if (el) el.innerHTML = '
FETCHING EMAIL…
'; + const d = await api('email_inbox', {account: acct, ...(force?{force:1}:{})}); + if (d.error) { el.innerHTML = `
${d.error}
`; return; } + // Update action item badge + const badge = document.getElementById('email-ai-badge'); + if (badge && d.action_items_count) badge.textContent = d.action_items_count; else if(badge) badge.textContent = ''; + const msgs = d.summary?.recent || []; + if (!msgs.length) { el.innerHTML='
No messages.
'; return; } + const rows = msgs.map(m => { + const ai = m.action_type ? `${m.action_type.toUpperCase()} ` : ''; + const unread = m.unread ? ` ` : ''; + const acctBadge = m.account ? `[${m.account.toUpperCase()}]` : ''; + return ` + ${unread} + ${esc(m.from_name||m.from_email||'')} + ${ai}${esc(m.subject||'')} + ${esc(m.date||'')} ${acctBadge} + ${esc((m.preview||'').substring(0,120))} + `; + }).join(''); + el.innerHTML = `${rows}
FROMSUBJECTDATEPREVIEW
`; +} + +async function loadEmailActionItems() { + const el = document.getElementById('email-actions-tbl'); + if (!el) return; + const d = await api('email_action_items'); + const items = d.action_items || []; + const badge = document.getElementById('email-ai-badge'); + if (badge) badge.textContent = items.length || ''; + if (!items.length) { el.innerHTML='
No action items pending — inbox is clear.
'; return; } + const rows = items.map(it => { + const typeColor = it.action_type==='appointment' ? 'var(--cyan)' : 'var(--orange)'; + const sugDate = it.suggested_date ? `` : ``; + const titleIn = ``; + const btnTask = ``; + const btnAppt = ``; + const btnDismiss = ``; + return ` + ${it.action_type.toUpperCase()} + ${esc(it.from_name||it.from_email||'')} + ${titleIn} + ${sugDate} + ${btnTask} ${btnAppt} ${btnDismiss} + `; + }).join(''); + el.innerHTML = `${rows}
TYPEFROMTITLEDATEACTIONS
`; +} + +function emailMakeTask(id) { + const title = document.getElementById('eat-'+id)?.value || ''; + const due = document.getElementById('ead-'+id)?.value || ''; + apiPost('email_create_task',{id,title,due_date:due},()=>{ toast('Task created','ok'); loadEmailActionItems(); loadTasks(); }); +} +function emailMakeAppt(id) { + const title = document.getElementById('eat-'+id)?.value || ''; + const dateVal = document.getElementById('ead-'+id)?.value || ''; + const start = dateVal ? dateVal + 'T09:00' : ''; + apiPost('email_create_appt',{id,title,start_at:start},()=>{ toast('Appointment created','ok'); loadEmailActionItems(); loadAppts(); }); +} +function emailDismiss(id) { + apiPost('email_dismiss',{id},()=>{ toast('Dismissed','ok'); loadEmailActionItems(); }); +} + // ── PLANNER ───────────────────────────────────────────────────────────────── const _PRI_COLOR = {urgent:'var(--red)',high:'var(--orange)',normal:'var(--text)',low:'var(--border2)'};