From 7b9e819b5a66197f69cf47997d29593729f8d50f Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Thu, 5 Mar 2026 20:18:44 -0800 Subject: [PATCH 01/26] feat: add unified email/messaging management system Adds Messages page with Gmail, Outlook, and Teams account management, sync coordination with per-account caching, draft queue with AI generation stub, and Playwright selector configuration for DOM scraping. --- client/src/App.jsx | 3 + client/src/components/Layout.jsx | 5 +- .../src/components/messages/AccountsTab.jsx | 165 ++++++++++++++ client/src/components/messages/DraftsTab.jsx | 133 ++++++++++++ client/src/components/messages/InboxTab.jsx | 112 ++++++++++ .../src/components/messages/MessageDetail.jsx | 119 ++++++++++ client/src/components/messages/SyncTab.jsx | 181 ++++++++++++++++ client/src/pages/Messages.jsx | 105 +++++++++ client/src/services/api.js | 32 +++ server/index.js | 2 + server/lib/fileUtils.js | 3 +- server/routes/messages.js | 204 ++++++++++++++++++ server/services/messageAccounts.js | 83 +++++++ server/services/messageDrafts.js | 80 +++++++ server/services/messageGmailSync.js | 21 ++ server/services/messagePlaywrightSync.js | 53 +++++ server/services/messageSender.js | 35 +++ server/services/messageSync.js | 122 +++++++++++ 18 files changed, 1456 insertions(+), 2 deletions(-) create mode 100644 client/src/components/messages/AccountsTab.jsx create mode 100644 client/src/components/messages/DraftsTab.jsx create mode 100644 client/src/components/messages/InboxTab.jsx create mode 100644 client/src/components/messages/MessageDetail.jsx create mode 100644 client/src/components/messages/SyncTab.jsx create mode 100644 client/src/pages/Messages.jsx create mode 100644 server/routes/messages.js create mode 100644 server/services/messageAccounts.js create mode 100644 server/services/messageDrafts.js create mode 100644 server/services/messageGmailSync.js create mode 100644 server/services/messagePlaywrightSync.js create mode 100644 server/services/messageSender.js create mode 100644 server/services/messageSync.js diff --git a/client/src/App.jsx b/client/src/App.jsx index f14cf41a..f9b84506 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -51,6 +51,7 @@ const CyberCity = lazyWithReload(() => import('./pages/CyberCity')); const AppDetail = lazyWithReload(() => import('./pages/AppDetail')); const FeatureAgents = lazyWithReload(() => import('./pages/FeatureAgents')); const FeatureAgentDetail = lazyWithReload(() => import('./pages/FeatureAgentDetail')); +const Messages = lazyWithReload(() => import('./pages/Messages')); // Loading fallback for lazy-loaded pages const PageLoader = () => ( @@ -106,6 +107,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 40eeef4b..285b49c6 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -57,7 +57,8 @@ import { Database, Shield, Wand2, - Zap + Zap, + Mail } from 'lucide-react'; /* global __APP_VERSION__ */ import Logo from './Logo'; @@ -171,6 +172,7 @@ const navItems = [ { to: '/meatspace/post', label: 'POST', icon: Zap } ] }, + { to: '/messages/inbox', label: 'Messages', icon: Mail, single: true }, { to: '/security', label: 'Security', icon: Camera, single: true }, { to: '/settings', label: 'Settings', icon: Settings, single: true }, { to: '/shell', label: 'Shell', icon: SquareTerminal, single: true }, @@ -604,6 +606,7 @@ export default function Layout() { location.pathname.startsWith('/feature-agents') || location.pathname.startsWith('/insights') || location.pathname.startsWith('/meatspace') || + location.pathname.startsWith('/messages') || location.pathname.startsWith('/agents') || location.pathname === '/shell' || location.pathname.startsWith('/city') || diff --git a/client/src/components/messages/AccountsTab.jsx b/client/src/components/messages/AccountsTab.jsx new file mode 100644 index 00000000..975d5e27 --- /dev/null +++ b/client/src/components/messages/AccountsTab.jsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import { Plus, Trash2, RefreshCw, Mail, Globe, MessageSquare } from 'lucide-react'; +import toast from 'react-hot-toast'; +import * as api from '../../services/api'; + +const TYPE_ICONS = { gmail: Mail, outlook: Globe, teams: MessageSquare }; +const TYPE_LABELS = { gmail: 'Gmail (MCP)', outlook: 'Outlook (Playwright)', teams: 'Teams (Playwright)' }; + +export default function AccountsTab({ accounts, onRefresh }) { + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ name: '', type: 'gmail', email: '' }); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(null); + + const handleCreate = async () => { + if (!form.name) return toast.error('Name is required'); + setSaving(true); + await api.createMessageAccount(form); + setSaving(false); + setShowForm(false); + setForm({ name: '', type: 'gmail', email: '' }); + toast.success('Account created'); + onRefresh(); + }; + + const handleDelete = async (id) => { + setDeleting(id); + await api.deleteMessageAccount(id); + setDeleting(null); + toast.success('Account deleted'); + onRefresh(); + }; + + const handleToggle = async (account) => { + await api.updateMessageAccount(account.id, { enabled: !account.enabled }); + toast.success(account.enabled ? 'Account disabled' : 'Account enabled'); + onRefresh(); + }; + + return ( +
+
+

Accounts

+ +
+ + {showForm && ( +
+
+ + setForm(f => ({ ...f, name: e.target.value }))} + placeholder="e.g. Work Gmail" + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-port-accent" + /> +
+
+ + +
+
+ + setForm(f => ({ ...f, email: e.target.value }))} + placeholder="user@example.com" + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-port-accent" + /> +
+
+ + +
+
+ )} + + {accounts.length === 0 && !showForm && ( +
+ +

No accounts configured

+

Add a Gmail, Outlook, or Teams account to get started

+
+ )} + +
+ {accounts.map((account) => { + const Icon = TYPE_ICONS[account.type] || Mail; + return ( +
+
+ +
+
{account.name}
+
+ {TYPE_LABELS[account.type]} ยท {account.email || 'No email set'} +
+ {account.lastSyncAt && ( +
+ Last sync: {new Date(account.lastSyncAt).toLocaleString()} ({account.lastSyncStatus}) +
+ )} +
+
+
+ + +
+
+ ); + })} +
+
+ ); +} diff --git a/client/src/components/messages/DraftsTab.jsx b/client/src/components/messages/DraftsTab.jsx new file mode 100644 index 00000000..b51bc65e --- /dev/null +++ b/client/src/components/messages/DraftsTab.jsx @@ -0,0 +1,133 @@ +import { useState, useEffect, useCallback } from 'react'; +import { FileText, Trash2, Send, Check, RefreshCw } from 'lucide-react'; +import toast from 'react-hot-toast'; +import * as api from '../../services/api'; + +export default function DraftsTab({ accounts }) { + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchDrafts = useCallback(async () => { + setLoading(true); + const data = await api.getMessageDrafts().catch(() => []); + setDrafts(data || []); + setLoading(false); + }, []); + + useEffect(() => { + fetchDrafts(); + }, [fetchDrafts]); + + const handleApprove = async (id) => { + await api.approveMessageDraft(id); + setDrafts(prev => prev.map(d => d.id === id ? { ...d, status: 'approved' } : d)); + toast.success('Draft approved'); + }; + + const handleSend = async (id) => { + const result = await api.sendMessageDraft(id).catch(() => null); + if (result) { + setDrafts(prev => prev.map(d => d.id === id ? { ...d, status: 'sent' } : d)); + toast.success('Message sent'); + } + }; + + const handleDelete = async (id) => { + await api.deleteMessageDraft(id); + setDrafts(prev => prev.filter(d => d.id !== id)); + toast.success('Draft deleted'); + }; + + const getAccountName = (accountId) => { + const account = accounts.find(a => a.id === accountId); + return account?.name || 'Unknown'; + }; + + const statusColors = { + draft: 'bg-gray-700 text-gray-300', + pending_review: 'bg-port-warning/20 text-port-warning', + approved: 'bg-port-success/20 text-port-success', + sending: 'bg-port-accent/20 text-port-accent', + sent: 'bg-port-success/20 text-port-success', + failed: 'bg-port-error/20 text-port-error' + }; + + return ( +
+
+

Drafts

+ +
+ + {drafts.length === 0 && !loading && ( +
+ +

No drafts

+

Generate AI replies from the Inbox or create manual drafts

+
+ )} + +
+ {drafts.map((draft) => ( +
+
+
+ + {draft.status} + + {getAccountName(draft.accountId)} + {draft.generatedBy === 'ai' && ( + AI generated + )} +
+
+ {draft.status === 'draft' && ( + + )} + {draft.status === 'approved' && ( + + )} + {['draft', 'pending_review', 'failed'].includes(draft.status) && ( + + )} +
+
+
{draft.subject || '(no subject)'}
+ {draft.to?.length > 0 && ( +
To: {draft.to.join(', ')}
+ )} +
+ {draft.body} +
+
+ ))} +
+
+ ); +} diff --git a/client/src/components/messages/InboxTab.jsx b/client/src/components/messages/InboxTab.jsx new file mode 100644 index 00000000..3ac01dee --- /dev/null +++ b/client/src/components/messages/InboxTab.jsx @@ -0,0 +1,112 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Mail, Search, RefreshCw, ChevronRight } from 'lucide-react'; +import * as api from '../../services/api'; +import MessageDetail from './MessageDetail'; + +export default function InboxTab({ accounts }) { + const [messages, setMessages] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [selectedAccount, setSelectedAccount] = useState(''); + const [selectedMessage, setSelectedMessage] = useState(null); + + const fetchMessages = useCallback(async () => { + setLoading(true); + const params = {}; + if (selectedAccount) params.accountId = selectedAccount; + if (search) params.search = search; + const result = await api.getMessageInbox(params).catch(() => ({ messages: [], total: 0 })); + setMessages(result.messages || []); + setTotal(result.total || 0); + setLoading(false); + }, [selectedAccount, search]); + + useEffect(() => { + fetchMessages(); + }, [fetchMessages]); + + if (selectedMessage) { + return ( + setSelectedMessage(null)} + /> + ); + } + + return ( +
+
+
+ + setSearch(e.target.value)} + placeholder="Search messages..." + className="w-full pl-9 pr-3 py-2 bg-port-bg border border-port-border rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-port-accent" + /> +
+ + +
+ +
{total} messages
+ + {messages.length === 0 && !loading && ( +
+ +

No messages yet

+

Add an account and sync to get started

+
+ )} + +
+ {messages.map((msg) => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/messages/MessageDetail.jsx b/client/src/components/messages/MessageDetail.jsx new file mode 100644 index 00000000..6fe01580 --- /dev/null +++ b/client/src/components/messages/MessageDetail.jsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { ArrowLeft, Reply, Sparkles, Send } from 'lucide-react'; +import toast from 'react-hot-toast'; +import * as api from '../../services/api'; + +export default function MessageDetail({ message, accounts, onBack }) { + const [showReply, setShowReply] = useState(false); + const [replyBody, setReplyBody] = useState(''); + const [generating, setGenerating] = useState(false); + + const account = accounts.find(a => a.id === message.accountId) || accounts[0]; + + const handleGenerateReply = async () => { + if (!account) return toast.error('No account available'); + setGenerating(true); + const draft = await api.generateMessageDraft({ + accountId: account.id, + replyToMessageId: message.id, + threadId: message.threadId, + context: `Replying to: "${message.subject}" from ${message.from?.name || message.from?.email}`, + instructions: '' + }).catch(() => null); + setGenerating(false); + if (draft) { + setReplyBody(draft.body); + setShowReply(true); + toast.success('AI draft generated'); + } + }; + + const handleCreateDraft = async () => { + if (!account) return toast.error('No account available'); + await api.createMessageDraft({ + accountId: account.id, + replyToMessageId: message.id, + threadId: message.threadId, + to: [message.from?.email].filter(Boolean), + subject: `Re: ${message.subject || ''}`, + body: replyBody, + generatedBy: 'manual', + sendVia: account.provider + }); + toast.success('Draft saved'); + setShowReply(false); + setReplyBody(''); + }; + + return ( +
+ + +
+

{message.subject || '(no subject)'}

+
+ + From: {message.from?.name || message.from?.email || 'Unknown'} + + {message.date && ( + {new Date(message.date).toLocaleString()} + )} +
+ {message.to?.length > 0 && ( +
To: {message.to.map(t => t.email || t).join(', ')}
+ )} +
+ {message.bodyText || '(no content)'} +
+
+ +
+ + +
+ + {showReply && ( +
+