feat: email intelligence — action item detection, task/appt creation, admin EMAIL tab, full voice intents

This commit is contained in:
2026-05-31 18:57:47 +00:00
parent f122de483a
commit 7c1cfda588
3 changed files with 423 additions and 128 deletions
+173 -74
View File
@@ -1,139 +1,238 @@
<?php
// JARVIS Email endpoint — IMAP reader for Gmail, Outlook, iCloud.
// GET ?action=email → recent + unread summary (cached 5 min)
// GET ?action=email&account=gmail → Gmail only
// GET ?action=email&force=1 → bypass cache, fetch live
// GET ?action=email&search=from:john → search emails
// JARVIS Email — IMAP reader + action item extractor for Gmail, Outlook, iCloud.
// GET ?account=all|gmail|outlook|icloud → recent + unread summary (cached 5 min)
// GET ?action=action_items → detected tasks/appointments from emails
// POST action=create_task → create task from email_action id
// POST action=create_appt → create appointment from email_action id
// POST action=dismiss → dismiss email_action
// GET ?force=1 → bypass cache
function imapFetch(string $host, string $user, string $pass, int $maxMsgs = 10): array {
// ── IMAP fetch ────────────────────────────────────────────────────────────────
function imapFetch(string $host, string $user, string $pass, int $maxMsgs = 15): array {
if (!$user || !$pass) return ['error' => '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);