Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7b9e819
feat: add unified email/messaging management system
atomantic Mar 6, 2026
1fcd989
address review: fix error handling, naming, sync resilience, and inpu…
atomantic Mar 6, 2026
b6adc4f
address review: fix path traversal, delete handler, and sender fallback
atomantic Mar 6, 2026
985c128
address review: fix route ordering, schema validation, and prototype …
atomantic Mar 6, 2026
d7eb66b
address review: debounce search, validate draft account, cleanup on d…
atomantic Mar 6, 2026
f679a9f
feat: implement CDP browser sync for Outlook/Teams messages
atomantic Mar 6, 2026
baf6b23
address review: schema consistency, UUID validation, sync:failed list…
atomantic Mar 6, 2026
2c2280f
address review: cache resilience, selector injection, WebSocket leak,…
atomantic Mar 6, 2026
eb49710
address review: per-account sync locking and structured provider status
atomantic Mar 6, 2026
8349d60
address review: sendVia validation, disabled account guard, structure…
atomantic Mar 6, 2026
9aee426
address review: structured sync returns, draft dedup, test contract a…
atomantic Mar 6, 2026
bd89dc4
address review: CDP timer cleanup, WS parse safety, consistent struct…
atomantic Mar 6, 2026
8a7c408
address review: move message detail route to end to avoid capturing l…
atomantic Mar 6, 2026
c9b9ef6
address review: add type guards after safeJSONParse for all message d…
atomantic Mar 6, 2026
2ba7f39
address review: structured stub returns, send result checking, CDP de…
atomantic Mar 6, 2026
10927e0
address review: empty selector guard, NaN-safe date sort, update sche…
atomantic Mar 6, 2026
34bee00
address review: include accountId in getMessage, emit failed for non-…
atomantic Mar 6, 2026
4fed50c
fix(apps): label Vite dev ports as devUi when API process coexists
atomantic Mar 6, 2026
64a602c
address review: drafts UUID validation, fix duplicate sync events, do…
atomantic Mar 6, 2026
425b83d
address review: always emit sync:completed, reactive account updates
atomantic Mar 6, 2026
4d9a473
fix(messages): always render selector cards for supported providers
atomantic Mar 6, 2026
6b6bad9
address review: structured sync errors, remove duplicate toasts, add …
atomantic Mar 6, 2026
7151fe7
address review: docs consistency, dead prop, deleteCache logging, tes…
atomantic Mar 6, 2026
7247003
address review: Vite detection by VITE_PORT, PORTS.UI naming, conditi…
atomantic Mar 6, 2026
00a5439
fix: VITE_PORT references PORTS.UI consistently in pm2 template
atomantic Mar 6, 2026
094f77d
address review: align port guidance, fix devUiPort heuristics, remove…
atomantic Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
Expand Down Expand Up @@ -106,6 +107,8 @@ export default function App() {
<Route path="instances" element={<Instances />} />
<Route path="meatspace" element={<Navigate to="/meatspace/overview" replace />} />
<Route path="meatspace/:tab" element={<MeatSpace />} />
<Route path="messages" element={<Navigate to="/messages/inbox" replace />} />
<Route path="messages/:tab" element={<Messages />} />
<Route path="jira" element={<Navigate to="/devtools/jira" replace />} />
<Route path="devtools/jira" element={<Jira />} />
<Route path="city" element={<CyberCity />} />
Expand Down
5 changes: 4 additions & 1 deletion client/src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ import {
Database,
Shield,
Wand2,
Zap
Zap,
Mail
} from 'lucide-react';
/* global __APP_VERSION__ */
import Logo from './Logo';
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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') ||
Expand Down
168 changes: 168 additions & 0 deletions client/src/components/messages/AccountsTab.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Accounts</h2>
<button
onClick={() => setShowForm(!showForm)}
className="flex items-center gap-2 px-3 py-2 bg-port-accent text-white rounded-lg text-sm hover:bg-port-accent/80 transition-colors"
>
<Plus size={16} />
Add Account
</button>
</div>

{showForm && (
<div className="p-4 bg-port-card rounded-lg border border-port-border space-y-3">
<div>
<label className="block text-sm text-gray-400 mb-1">Name</label>
<input
type="text"
value={form.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Type</label>
<select
value={form.type}
onChange={(e) => setForm(f => ({ ...f, type: e.target.value }))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-sm text-white focus:outline-none focus:border-port-accent"
>
<option value="gmail">Gmail (MCP)</option>
<option value="outlook">Outlook (Playwright)</option>
<option value="teams">Teams (Playwright)</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Email</label>
<input
type="email"
value={form.email}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={saving}
className="px-4 py-2 bg-port-accent text-white rounded-lg text-sm hover:bg-port-accent/80 transition-colors disabled:opacity-50"
>
{saving ? 'Creating...' : 'Create'}
</button>
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 bg-port-border text-gray-300 rounded-lg text-sm hover:bg-port-border/80 transition-colors"
>
Cancel
</button>
</div>
</div>
)}

{accounts.length === 0 && !showForm && (
<div className="text-center py-12 text-gray-500">
<Mail size={48} className="mx-auto mb-4 opacity-50" />
<p>No accounts configured</p>
<p className="text-sm mt-1">Add a Gmail, Outlook, or Teams account to get started</p>
</div>
)}

<div className="space-y-2">
{accounts.map((account) => {
const Icon = TYPE_ICONS[account.type] || Mail;
return (
<div
key={account.id}
className="flex items-center justify-between p-4 bg-port-card rounded-lg border border-port-border"
>
<div className="flex items-center gap-3">
<Icon size={20} className={account.enabled ? 'text-port-accent' : 'text-gray-600'} />
<div>
<div className="text-sm font-medium text-white">{account.name}</div>
<div className="text-xs text-gray-500">
{TYPE_LABELS[account.type]} · {account.email || 'No email set'}
</div>
{account.lastSyncAt && (
<div className="text-xs text-gray-600">
Last sync: {new Date(account.lastSyncAt).toLocaleString()} ({account.lastSyncStatus})
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleToggle(account)}
className={`px-2 py-1 rounded text-xs transition-colors ${
account.enabled
? 'bg-port-success/20 text-port-success'
: 'bg-gray-700 text-gray-400'
}`}
>
{account.enabled ? 'Enabled' : 'Disabled'}
</button>
<button
onClick={() => handleDelete(account.id)}
disabled={deleting === account.id}
className="p-1 text-gray-500 hover:text-port-error transition-colors"
title="Delete account"
>
{deleting === account.id ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<Trash2 size={16} />
)}
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
134 changes: 134 additions & 0 deletions client/src/components/messages/DraftsTab.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Drafts</h2>
<button
onClick={fetchDrafts}
className="p-2 text-gray-400 hover:text-white transition-colors"
>
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>

{drafts.length === 0 && !loading && (
<div className="text-center py-12 text-gray-500">
<FileText size={48} className="mx-auto mb-4 opacity-50" />
<p>No drafts</p>
<p className="text-sm mt-1">Generate AI replies from the Inbox or create manual drafts</p>
</div>
)}

<div className="space-y-2">
{drafts.map((draft) => (
<div
key={draft.id}
className="p-4 bg-port-card rounded-lg border border-port-border space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[draft.status] || ''}`}>
{draft.status}
</span>
<span className="text-xs text-gray-500">{getAccountName(draft.accountId)}</span>
{draft.generatedBy === 'ai' && (
<span className="text-xs text-purple-400">AI generated</span>
)}
</div>
<div className="flex items-center gap-1">
{draft.status === 'draft' && (
<button
onClick={() => handleApprove(draft.id)}
className="p-1 text-gray-400 hover:text-port-success transition-colors"
title="Approve"
>
<Check size={16} />
</button>
)}
{draft.status === 'approved' && (
<button
onClick={() => handleSend(draft.id)}
className="p-1 text-gray-400 hover:text-port-accent transition-colors"
title="Send"
>
<Send size={16} />
</button>
)}
{['draft', 'pending_review', 'failed'].includes(draft.status) && (
<button
onClick={() => handleDelete(draft.id)}
className="p-1 text-gray-400 hover:text-port-error transition-colors"
title="Delete"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
<div className="text-sm text-white">{draft.subject || '(no subject)'}</div>
{draft.to?.length > 0 && (
<div className="text-xs text-gray-500">To: {draft.to.join(', ')}</div>
)}
<div className="text-sm text-gray-400 whitespace-pre-wrap line-clamp-3">
{draft.body}
</div>
</div>
))}
</div>
</div>
);
}
Loading