diff --git a/CLAUDE.md b/CLAUDE.md index 7e3f52aa..299e4c3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ pm2 logs ## Security Model -PortOS is a single-user, internal tool running on a private network behind Tailscale VPN. It is not exposed to the public internet. As such, it intentionally omits authentication, CORS restrictions, rate limiting, and HTTPS — these are non-issues for its deployment context. Do not add these or flag their absence as security concerns. +PortOS is a **single-user, single-instance** application running on a private network behind Tailscale VPN. It is not exposed to the public internet. There is exactly one user and one server process — concurrent request races, mutex locking on file I/O, and atomic write patterns are unnecessary and should not be added or flagged as concerns. Simple re-entrancy guards (e.g., per-account sync locks to prevent duplicate in-flight operations) are fine and expected. As such, it intentionally omits authentication, CORS restrictions, rate limiting, HTTPS, and full concurrency controls — these are non-issues for its deployment context. Do not add these or flag their absence as security concerns. The default database password `portos` (in `ecosystem.config.cjs`, `docker-compose.yml`, and `.env.example`) is an intentional backward-compatible fallback for local development. Do not remove it or flag it as a security concern. Production deployments override it via the `PGPASSWORD` environment variable. 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..9462e983 --- /dev/null +++ b/client/src/components/messages/AccountsTab.jsx @@ -0,0 +1,168 @@ +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, setAccounts }) { + 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); + const result = await api.createMessageAccount(form).catch(() => null); + setSaving(false); + if (!result) return toast.error('Failed to create account'); + setShowForm(false); + setForm({ name: '', type: 'gmail', email: '' }); + toast.success('Account created'); + setAccounts(prev => [...prev, result]); + }; + + const handleDelete = async (id) => { + setDeleting(id); + const ok = await api.deleteMessageAccount(id).then(() => true).catch(() => false); + setDeleting(null); + if (!ok) return; + toast.success('Account deleted'); + setAccounts(prev => prev.filter(a => a.id !== id)); + }; + + const handleToggle = async (account) => { + const result = await api.updateMessageAccount(account.id, { enabled: !account.enabled }).catch(() => null); + if (!result) return toast.error('Failed to update account'); + toast.success(account.enabled ? 'Account disabled' : 'Account enabled'); + setAccounts(prev => prev.map(a => a.id === account.id ? { ...a, enabled: !a.enabled } : a)); + }; + + 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..40ae9915 --- /dev/null +++ b/client/src/components/messages/DraftsTab.jsx @@ -0,0 +1,134 @@ +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) => { + const result = await api.approveMessageDraft(id).catch(() => null); + if (!result) return; + 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 || result.success === false) return; + setDrafts(prev => prev.map(d => d.id === id ? { ...d, status: 'sent' } : d)); + toast.success('Message sent'); + }; + + const handleDelete = async (id) => { + const ok = await api.deleteMessageDraft(id).then(() => true).catch(() => false); + if (!ok) return; + 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..c717e5b0 --- /dev/null +++ b/client/src/components/messages/InboxTab.jsx @@ -0,0 +1,120 @@ +import { useState, useEffect, useCallback, useRef } 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 [debouncedSearch, setDebouncedSearch] = useState(''); + const [selectedAccount, setSelectedAccount] = useState(''); + const [selectedMessage, setSelectedMessage] = useState(null); + const debounceRef = useRef(null); + + useEffect(() => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(debounceRef.current); + }, [search]); + + const fetchMessages = useCallback(async () => { + setLoading(true); + const params = {}; + if (selectedAccount) params.accountId = selectedAccount; + if (debouncedSearch) params.search = debouncedSearch; + const result = await api.getMessageInbox(params).catch(() => ({ messages: [], total: 0 })); + setMessages(result.messages || []); + setTotal(result.total || 0); + setLoading(false); + }, [selectedAccount, debouncedSearch]); + + 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..d17cd5b8 --- /dev/null +++ b/client/src/components/messages/MessageDetail.jsx @@ -0,0 +1,124 @@ +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 [generatedDraftId, setGeneratedDraftId] = useState(null); + + 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); + setGeneratedDraftId(draft.id); + setShowReply(true); + toast.success('AI draft generated'); + } + }; + + const handleCreateDraft = async () => { + if (!account) return toast.error('No account available'); + const to = [message.from?.email].filter(Boolean); + const subject = `Re: ${message.subject || ''}`; + const result = generatedDraftId + ? await api.updateMessageDraft(generatedDraftId, { to, subject, body: replyBody }).catch(() => null) + : await api.createMessageDraft({ + accountId: account.id, + replyToMessageId: message.id, + threadId: message.threadId, + to, subject, body: replyBody, + generatedBy: 'manual' + }).catch(() => null); + if (!result) return; + toast.success('Draft saved'); + setShowReply(false); + setReplyBody(''); + setGeneratedDraftId(null); + }; + + 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 && ( +
+