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 && (
+
+ )}
+
+ );
+}
diff --git a/client/src/components/messages/SyncTab.jsx b/client/src/components/messages/SyncTab.jsx
new file mode 100644
index 00000000..9c4fe1a8
--- /dev/null
+++ b/client/src/components/messages/SyncTab.jsx
@@ -0,0 +1,218 @@
+import { useState, useEffect, useCallback } from 'react';
+import { RefreshCw, Play, AlertCircle, Settings, Globe } from 'lucide-react';
+import toast from 'react-hot-toast';
+import * as api from '../../services/api';
+import socket from '../../services/socket';
+
+// Default selectors for supported providers — ensures editor cards always render,
+// even on fresh installs before selectors.json exists.
+const DEFAULT_SELECTORS = {
+ outlook: { messageRow: "[role='listitem']" },
+ teams: { messageItem: "[role='listitem']" },
+};
+
+export default function SyncTab({ accounts, onRefresh }) {
+ const [syncing, setSyncing] = useState({});
+ const [rawSelectors, setRawSelectors] = useState({});
+ const [editingSelector, setEditingSelector] = useState(null);
+ const [selectorForm, setSelectorForm] = useState({});
+
+ // Merge fetched selectors with defaults so every supported provider always appears
+ const selectors = Object.fromEntries(
+ Object.entries(DEFAULT_SELECTORS).map(([provider, defaults]) => [
+ provider,
+ { ...defaults, ...(rawSelectors[provider] || {}) },
+ ])
+ );
+
+ const fetchSelectors = useCallback(async () => {
+ const data = await api.getMessageSelectors().catch(() => ({}));
+ setRawSelectors(data || {});
+ }, []);
+
+ useEffect(() => {
+ fetchSelectors();
+
+ const onSyncStarted = ({ accountId }) => {
+ setSyncing(prev => ({ ...prev, [accountId]: 'syncing' }));
+ };
+ const onSyncCompleted = ({ accountId, newMessages, status }) => {
+ if (status && status !== 'success') return; // non-success handled by specific event (e.g. sync:auth-required)
+ setSyncing(prev => ({ ...prev, [accountId]: null }));
+ toast.success(`Sync complete: ${newMessages} new messages`);
+ onRefresh();
+ };
+ const onAuthRequired = ({ accountId }) => {
+ setSyncing(prev => ({ ...prev, [accountId]: 'auth-required' }));
+ toast('Login required -- open Browser page to authenticate', { icon: '\uD83D\uDD10' });
+ };
+ const onSyncFailed = ({ accountId, error }) => {
+ setSyncing(prev => ({ ...prev, [accountId]: null }));
+ toast.error(`Sync failed: ${error ?? 'unknown error'}`);
+ };
+
+ socket.on('messages:sync:started', onSyncStarted);
+ socket.on('messages:sync:completed', onSyncCompleted);
+ socket.on('messages:sync:auth-required', onAuthRequired);
+ socket.on('messages:sync:failed', onSyncFailed);
+
+ return () => {
+ socket.off('messages:sync:started', onSyncStarted);
+ socket.off('messages:sync:completed', onSyncCompleted);
+ socket.off('messages:sync:auth-required', onAuthRequired);
+ socket.off('messages:sync:failed', onSyncFailed);
+ };
+ }, [fetchSelectors, onRefresh]);
+
+ const handleLaunch = async (accountId) => {
+ const result = await api.launchMessageBrowser(accountId).catch(() => null);
+ if (result?.success) toast.success('Browser tab opened — log in if needed, then sync');
+ };
+
+ const handleSync = async (accountId) => {
+ setSyncing(prev => ({ ...prev, [accountId]: 'syncing' }));
+ await api.syncMessageAccount(accountId).catch(() => {
+ setSyncing(prev => ({ ...prev, [accountId]: null }));
+ });
+ };
+
+ const handleSaveSelectors = async (provider) => {
+ const result = await api.updateMessageSelectors(provider, selectorForm).catch(() => null);
+ if (!result) return;
+ toast.success(`${provider} selectors updated`);
+ setEditingSelector(null);
+ fetchSelectors();
+ };
+
+ const handleTestSelectors = async (provider) => {
+ const result = await api.testMessageSelectors(provider).catch(() => null);
+ if (result) toast.success(`Selector test: ${result.status}`);
+ };
+
+ return (
+
+ {/* Sync Status */}
+
+
Sync Status
+ {accounts.length === 0 && (
+
No accounts configured. Add one in the Accounts tab.
+ )}
+
+ {accounts.map((account) => (
+
+
+
{account.name}
+
+ {account.lastSyncAt
+ ? `Last sync: ${new Date(account.lastSyncAt).toLocaleString()}`
+ : 'Never synced'}
+ {account.lastSyncStatus && ` (${account.lastSyncStatus})`}
+
+
+
+ {syncing[account.id] === 'auth-required' && (
+
+ Auth required
+
+ )}
+ {account.provider === 'playwright' && (
+
+ )}
+ {syncing[account.id] === 'syncing' ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+ {/* Selector Configuration */}
+
+
DOM Selectors
+
+ Playwright selectors for scraping Outlook and Teams. Edit if the DOM structure changes.
+
+ {Object.entries(selectors).map(([provider, sels]) => (
+
+
+
{provider}
+
+
+
+
+
+ {editingSelector === provider ? (
+
+ {Object.entries(selectorForm).map(([key, val]) => (
+
+
+ setSelectorForm(f => ({ ...f, [key]: e.target.value }))}
+ className="flex-1 px-2 py-1 bg-port-bg border border-port-border rounded text-xs text-white font-mono focus:outline-none focus:border-port-accent"
+ />
+
+ ))}
+
+
+
+
+
+ ) : (
+
+ {Object.entries(sels).map(([key, val]) => (
+
+ {key}
+ {val}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/pages/Messages.jsx b/client/src/pages/Messages.jsx
new file mode 100644
index 00000000..c38041b4
--- /dev/null
+++ b/client/src/pages/Messages.jsx
@@ -0,0 +1,106 @@
+import { useParams, useNavigate } from 'react-router-dom';
+import { Mail, RefreshCw } from 'lucide-react';
+import { useState, useEffect, useCallback } from 'react';
+import * as api from '../services/api';
+
+import InboxTab from '../components/messages/InboxTab';
+import AccountsTab from '../components/messages/AccountsTab';
+import DraftsTab from '../components/messages/DraftsTab';
+import SyncTab from '../components/messages/SyncTab';
+
+const TABS = [
+ { id: 'inbox', label: 'Inbox', icon: Mail },
+ { id: 'accounts', label: 'Accounts', icon: Mail },
+ { id: 'drafts', label: 'Drafts', icon: Mail },
+ { id: 'sync', label: 'Sync', icon: RefreshCw }
+];
+
+export default function Messages() {
+ const { tab } = useParams();
+ const navigate = useNavigate();
+ const VALID_TABS = TABS.map(t => t.id);
+ const activeTab = VALID_TABS.includes(tab) ? tab : 'inbox';
+ const [accounts, setAccounts] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const fetchAccounts = useCallback(async () => {
+ const data = await api.getMessageAccounts().catch(() => []);
+ setAccounts(data || []);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ fetchAccounts();
+ }, [fetchAccounts]);
+
+ const handleTabChange = (tabId) => {
+ navigate(`/messages/${tabId}`);
+ };
+
+ const renderTabContent = () => {
+ switch (activeTab) {
+ case 'inbox':
+ return ;
+ case 'accounts':
+ return ;
+ case 'drafts':
+ return ;
+ case 'sync':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
Messages
+
Unified email and messaging management
+
+
+
+ {accounts.length} accounts
+
+
+
+
+ {TABS.map((tabItem) => {
+ const Icon = tabItem.icon;
+ const isActive = activeTab === tabItem.id;
+ return (
+
+ );
+ })}
+
+
+
+ {renderTabContent()}
+
+
+ );
+}
diff --git a/client/src/services/api.js b/client/src/services/api.js
index bf0edb0d..67e0a6e2 100644
--- a/client/src/services/api.js
+++ b/client/src/services/api.js
@@ -1610,6 +1610,41 @@ export const setGitHubSecret = (name, value) =>
export const syncGitHubSecret = (name) =>
request(`/github/secrets/${encodeURIComponent(name)}/sync`, { method: 'POST' });
+// Messages
+export const getMessageAccounts = () => request('/messages/accounts');
+export const createMessageAccount = (data) => request('/messages/accounts', { method: 'POST', body: JSON.stringify(data) });
+export const updateMessageAccount = (id, data) => request(`/messages/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) });
+export const deleteMessageAccount = (id) => request(`/messages/accounts/${id}`, { method: 'DELETE' });
+export const syncMessageAccount = (accountId) => request(`/messages/sync/${accountId}`, { method: 'POST' });
+export const getMessageSyncStatus = (accountId) => request(`/messages/sync/${accountId}/status`);
+export const getMessageInbox = (params = {}) => {
+ const qs = new URLSearchParams();
+ if (params.accountId) qs.set('accountId', params.accountId);
+ if (params.search) qs.set('search', params.search);
+ if (params.limit) qs.set('limit', params.limit);
+ if (params.offset) qs.set('offset', params.offset);
+ const str = qs.toString();
+ return request(`/messages/inbox${str ? `?${str}` : ''}`);
+};
+export const getMessageDetail = (accountId, messageId) => request(`/messages/${accountId}/${messageId}`);
+export const getMessageDrafts = (params = {}) => {
+ const qs = new URLSearchParams();
+ if (params.accountId) qs.set('accountId', params.accountId);
+ if (params.status) qs.set('status', params.status);
+ const str = qs.toString();
+ return request(`/messages/drafts${str ? `?${str}` : ''}`);
+};
+export const createMessageDraft = (data) => request('/messages/drafts', { method: 'POST', body: JSON.stringify(data) });
+export const generateMessageDraft = (data) => request('/messages/drafts/generate', { method: 'POST', body: JSON.stringify(data) });
+export const updateMessageDraft = (id, data) => request(`/messages/drafts/${id}`, { method: 'PUT', body: JSON.stringify(data) });
+export const approveMessageDraft = (id) => request(`/messages/drafts/${id}/approve`, { method: 'POST' });
+export const sendMessageDraft = (id) => request(`/messages/drafts/${id}/send`, { method: 'POST' });
+export const deleteMessageDraft = (id) => request(`/messages/drafts/${id}`, { method: 'DELETE' });
+export const getMessageSelectors = () => request('/messages/selectors');
+export const updateMessageSelectors = (provider, selectors) => request(`/messages/selectors/${provider}`, { method: 'PUT', body: JSON.stringify({ selectors }) });
+export const testMessageSelectors = (provider) => request(`/messages/selectors/${provider}/test`, { method: 'POST' });
+export const launchMessageBrowser = (accountId) => request(`/messages/launch/${accountId}`, { method: 'POST' });
+
// Default export for simplified imports
export default {
get: (endpoint, options) => request(endpoint, { method: 'GET', ...options }),
diff --git a/server/index.js b/server/index.js
index 76eba9e2..1625fa5b 100644
--- a/server/index.js
+++ b/server/index.js
@@ -36,6 +36,7 @@ import notificationsRoutes from './routes/notifications.js';
import standardizeRoutes from './routes/standardize.js';
import brainRoutes from './routes/brain.js';
import mediaRoutes from './routes/media.js';
+import messagesRoutes from './routes/messages.js';
import genomeRoutes from './routes/genome.js';
import digitalTwinRoutes from './routes/digital-twin.js';
import socialAccountsRoutes from './routes/socialAccounts.js';
@@ -226,6 +227,7 @@ app.use('/api/notifications', notificationsRoutes);
app.use('/api/standardize', standardizeRoutes);
app.use('/api/brain', brainRoutes);
app.use('/api/media', mediaRoutes);
+app.use('/api/messages', messagesRoutes);
app.use('/api/digital-twin/social-accounts', socialAccountsRoutes);
app.use('/api/meatspace/genome', genomeRoutes);
app.use('/api/digital-twin/identity', identityRoutes);
diff --git a/server/lib/fileUtils.js b/server/lib/fileUtils.js
index c92c8ef5..4e5d60b3 100644
--- a/server/lib/fileUtils.js
+++ b/server/lib/fileUtils.js
@@ -29,7 +29,8 @@ export const PATHS = {
reports: join(__lib_dirname, '../../data/cos/reports'),
// AI Agent Personalities data
agentPersonalities: join(__lib_dirname, '../../data/agents'),
- meatspace: join(__lib_dirname, '../../data/meatspace')
+ meatspace: join(__lib_dirname, '../../data/meatspace'),
+ messages: join(__lib_dirname, '../../data/messages')
};
/**
diff --git a/server/routes/apps.js b/server/routes/apps.js
index 777ad60c..1ddddf8d 100644
--- a/server/routes/apps.js
+++ b/server/routes/apps.js
@@ -97,6 +97,11 @@ router.get('/', asyncHandler(async (req, res) => {
const devUiProc = processes.find(p => p.ports?.devUi);
if (devUiProc) devUiPort = devUiProc.ports.devUi;
}
+ // When app has API + Vite dev processes but no dedicated UI port,
+ // the prod UI is served by the API server
+ if (!uiPort && apiPort && devUiPort) {
+ uiPort = apiPort;
+ }
return {
...app,
@@ -149,6 +154,11 @@ router.get('/:id', loadApp, asyncHandler(async (req, res) => {
const devUiProc = processes.find(p => p.ports?.devUi);
if (devUiProc) devUiPort = devUiProc.ports.devUi;
}
+ // When app has API + Vite dev processes but no dedicated UI port,
+ // the prod UI is served by the API server
+ if (!uiPort && apiPort && devUiPort) {
+ uiPort = apiPort;
+ }
// Read version from app's package.json if available
let appVersion = null;
diff --git a/server/routes/apps.test.js b/server/routes/apps.test.js
index 6693146d..217910bf 100644
--- a/server/routes/apps.test.js
+++ b/server/routes/apps.test.js
@@ -310,6 +310,28 @@ describe('Apps Routes', () => {
expect(response.body[0].devUiPort).toBe(5554);
});
+ it('should derive uiPort from apiPort when app has devUi but no ui process', async () => {
+ const mockApps = [{
+ id: 'app-001',
+ name: 'Test App',
+ pm2ProcessNames: ['test-api', 'test-ui'],
+ repoPath: '/tmp/test',
+ processes: [
+ { name: 'test-api', ports: { api: 5551 } },
+ { name: 'test-ui', ports: { devUi: 5550 } }
+ ]
+ }];
+ appsService.getAllApps.mockResolvedValue(mockApps);
+ pm2Service.listProcesses.mockResolvedValue([]);
+
+ const response = await request(app).get('/api/apps');
+
+ expect(response.status).toBe(200);
+ expect(response.body[0].uiPort).toBe(5551);
+ expect(response.body[0].devUiPort).toBe(5550);
+ expect(response.body[0].apiPort).toBe(5551);
+ });
+
it('should use explicit devUiPort over derived value', async () => {
const mockApps = [{
id: 'app-001',
diff --git a/server/routes/messages.js b/server/routes/messages.js
new file mode 100644
index 00000000..5c889f89
--- /dev/null
+++ b/server/routes/messages.js
@@ -0,0 +1,280 @@
+import express from 'express';
+import { z } from 'zod';
+import { asyncHandler } from '../lib/errorHandler.js';
+import { validateRequest } from '../lib/validation.js';
+import * as messageAccounts from '../services/messageAccounts.js';
+import * as messageSync from '../services/messageSync.js';
+import * as messageDrafts from '../services/messageDrafts.js';
+import * as messageSender from '../services/messageSender.js';
+import { getSelectors, updateSelectors, testSelectors, launchProvider } from '../services/messagePlaywrightSync.js';
+
+const router = express.Router();
+
+// === Validation Schemas ===
+const createAccountSchema = z.object({
+ name: z.string().min(1),
+ type: z.enum(['gmail', 'outlook', 'teams']),
+ email: z.union([z.string().email(), z.literal('')]).optional().default(''),
+ syncConfig: z.object({
+ maxAge: z.string().optional(),
+ maxMessages: z.number().int().positive().optional(),
+ syncInterval: z.number().int().positive().optional()
+ }).optional()
+});
+
+const updateAccountSchema = z.object({
+ name: z.string().min(1).optional(),
+ email: z.union([z.string().email(), z.literal('')]).optional(),
+ enabled: z.boolean().optional(),
+ syncConfig: z.object({
+ maxAge: z.string().optional(),
+ maxMessages: z.number().int().positive().optional(),
+ syncInterval: z.number().int().positive().optional()
+ }).optional()
+});
+
+const createDraftSchema = z.object({
+ accountId: z.string().uuid(),
+ replyToMessageId: z.string().optional(),
+ threadId: z.string().optional(),
+ to: z.array(z.string()).optional().default([]),
+ cc: z.array(z.string()).optional().default([]),
+ subject: z.string().optional().default(''),
+ body: z.string().optional().default(''),
+ generatedBy: z.enum(['ai', 'manual']).optional().default('manual'),
+ sendVia: z.enum(['mcp', 'playwright']).optional()
+});
+
+const updateDraftSchema = z.object({
+ to: z.array(z.string()).optional(),
+ cc: z.array(z.string()).optional(),
+ subject: z.string().optional(),
+ body: z.string().optional(),
+ status: z.enum(['draft', 'pending_review', 'approved']).optional()
+});
+
+const generateDraftSchema = z.object({
+ accountId: z.string().uuid(),
+ replyToMessageId: z.string().optional(),
+ threadId: z.string().optional(),
+ context: z.string().optional().default(''),
+ instructions: z.string().optional().default('')
+});
+
+const updateSelectorsSchema = z.object({
+ selectors: z.record(z.string())
+});
+
+// === Account Routes ===
+router.get('/accounts', asyncHandler(async (req, res) => {
+ const accounts = await messageAccounts.listAccounts();
+ res.json(accounts);
+}));
+
+router.post('/accounts', asyncHandler(async (req, res) => {
+ const data = validateRequest(createAccountSchema, req.body);
+ const account = await messageAccounts.createAccount(data);
+ req.app.get('io')?.emit('messages:changed', {});
+ res.status(201).json(account);
+}));
+
+router.put('/accounts/:id', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.id).success) {
+ return res.status(400).json({ error: 'Invalid account ID format' });
+ }
+ const updates = validateRequest(updateAccountSchema, req.body);
+ const account = await messageAccounts.updateAccount(req.params.id, updates);
+ if (!account) return res.status(404).json({ error: 'Account not found' });
+ req.app.get('io')?.emit('messages:changed', {});
+ res.json(account);
+}));
+
+router.delete('/accounts/:id', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.id).success) {
+ return res.status(400).json({ error: 'Invalid account ID format' });
+ }
+ const deleted = await messageAccounts.deleteAccount(req.params.id);
+ if (!deleted) return res.status(404).json({ error: 'Account not found' });
+ // Clean up related data
+ await messageSync.deleteCache(req.params.id).catch(() => {});
+ await messageDrafts.deleteDraftsByAccountId(req.params.id).catch(() => {});
+ req.app.get('io')?.emit('messages:changed', {});
+ res.status(204).send();
+}));
+
+// === Sync Routes ===
+router.post('/sync/:accountId', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.accountId).success) {
+ return res.status(400).json({ error: 'Invalid account ID format' });
+ }
+ const io = req.app.get('io');
+ const result = await messageSync.syncAccount(req.params.accountId, io);
+ if (result.error) return res.status(result.status || 404).json({ error: result.error });
+ res.json(result);
+}));
+
+router.get('/sync/:accountId/status', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.accountId).success) {
+ return res.status(400).json({ error: 'Invalid account ID format' });
+ }
+ const status = await messageSync.getSyncStatus(req.params.accountId);
+ if (!status) return res.status(404).json({ error: 'Account not found' });
+ res.json(status);
+}));
+
+// === Inbox Routes ===
+router.get('/inbox', asyncHandler(async (req, res) => {
+ const { accountId, search, limit, offset } = req.query;
+ if (accountId && !z.string().uuid().safeParse(accountId).success) {
+ return res.status(400).json({ error: 'Invalid accountId format' });
+ }
+ let parsedLimit = limit !== undefined ? parseInt(limit, 10) : 50;
+ if (Number.isNaN(parsedLimit) || parsedLimit <= 0) parsedLimit = 50;
+ if (parsedLimit > 100) parsedLimit = 100;
+ let parsedOffset = offset !== undefined ? parseInt(offset, 10) : 0;
+ if (Number.isNaN(parsedOffset) || parsedOffset < 0) parsedOffset = 0;
+ const result = await messageSync.getMessages({
+ accountId,
+ search,
+ limit: parsedLimit,
+ offset: parsedOffset
+ });
+ res.json(result);
+}));
+
+// === Draft Routes ===
+router.get('/drafts', asyncHandler(async (req, res) => {
+ const { accountId, status } = req.query;
+ if (accountId && !z.string().uuid().safeParse(accountId).success) {
+ return res.status(400).json({ error: 'Invalid accountId format' });
+ }
+ const drafts = await messageDrafts.listDrafts({ accountId, status });
+ res.json(drafts);
+}));
+
+router.post('/drafts', asyncHandler(async (req, res) => {
+ const data = validateRequest(createDraftSchema, req.body);
+ const account = await messageAccounts.getAccount(data.accountId);
+ if (!account) return res.status(404).json({ error: 'Account not found' });
+ const derivedSendVia = account.type === 'gmail' ? 'mcp' : 'playwright';
+ if (data.sendVia && data.sendVia !== derivedSendVia) {
+ return res.status(400).json({ error: `sendVia "${data.sendVia}" conflicts with account type "${account.type}" (expected "${derivedSendVia}")` });
+ }
+ data.sendVia = derivedSendVia;
+ const draft = await messageDrafts.createDraft(data);
+ req.app.get('io')?.emit('messages:draft:created', { draftId: draft.id });
+ res.status(201).json(draft);
+}));
+
+router.post('/drafts/generate', asyncHandler(async (req, res) => {
+ const data = validateRequest(generateDraftSchema, req.body);
+ const account = await messageAccounts.getAccount(data.accountId);
+ if (!account) return res.status(404).json({ error: 'Account not found' });
+ // AI draft generation - stub for now
+ // TODO: Use portos-ai-toolkit for provider selection and model tiers
+ const draft = await messageDrafts.createDraft({
+ accountId: data.accountId,
+ replyToMessageId: data.replyToMessageId,
+ threadId: data.threadId,
+ subject: '',
+ body: `[AI-generated reply placeholder]\n\nContext: ${data.context}\nInstructions: ${data.instructions}`,
+ generatedBy: 'ai',
+ sendVia: account.provider || (account.type === 'gmail' ? 'mcp' : 'playwright')
+ });
+ req.app.get('io')?.emit('messages:draft:created', { draftId: draft.id });
+ res.status(201).json(draft);
+}));
+
+router.put('/drafts/:id', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.id).success) {
+ return res.status(400).json({ error: 'Invalid draft ID format' });
+ }
+ const updates = validateRequest(updateDraftSchema, req.body);
+ const draft = await messageDrafts.updateDraft(req.params.id, updates);
+ if (!draft) return res.status(404).json({ error: 'Draft not found' });
+ res.json(draft);
+}));
+
+router.post('/drafts/:id/approve', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.id).success) {
+ return res.status(400).json({ error: 'Invalid draft ID format' });
+ }
+ const draft = await messageDrafts.approveDraft(req.params.id);
+ if (!draft) return res.status(404).json({ error: 'Draft not found' });
+ res.json(draft);
+}));
+
+router.post('/drafts/:id/send', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.id).success) {
+ return res.status(400).json({ error: 'Invalid draft ID format' });
+ }
+ const io = req.app.get('io');
+ const result = await messageSender.sendDraft(req.params.id, io);
+ if (!result.success) {
+ return res.status(result.status).json({ code: result.code, error: result.error });
+ }
+ res.json(result);
+}));
+
+router.delete('/drafts/:id', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.id).success) {
+ return res.status(400).json({ error: 'Invalid draft ID format' });
+ }
+ const deleted = await messageDrafts.deleteDraft(req.params.id);
+ if (!deleted) return res.status(404).json({ error: 'Draft not found' });
+ res.status(204).send();
+}));
+
+// === Browser Launch Route ===
+router.post('/launch/:accountId', asyncHandler(async (req, res) => {
+ if (!z.string().uuid().safeParse(req.params.accountId).success) {
+ return res.status(400).json({ error: 'Invalid account ID format' });
+ }
+ const account = await messageAccounts.getAccount(req.params.accountId);
+ if (!account) return res.status(404).json({ error: 'Account not found' });
+ if (account.type === 'gmail') return res.status(400).json({ error: 'Gmail uses MCP, not browser automation' });
+ const result = await launchProvider(account.type);
+ if (!result.success) return res.status(503).json({ error: result.error });
+ res.json(result);
+}));
+
+// === Selector Routes ===
+router.get('/selectors', asyncHandler(async (req, res) => {
+ const selectors = await getSelectors();
+ res.json(selectors);
+}));
+
+const ALLOWED_PROVIDERS = ['outlook', 'teams'];
+
+router.put('/selectors/:provider', asyncHandler(async (req, res) => {
+ if (!ALLOWED_PROVIDERS.includes(req.params.provider)) {
+ return res.status(400).json({ error: 'Invalid provider' });
+ }
+ const { selectors } = validateRequest(updateSelectorsSchema, req.body);
+ const updated = await updateSelectors(req.params.provider, selectors);
+ res.json(updated);
+}));
+
+router.post('/selectors/:provider/test', asyncHandler(async (req, res) => {
+ if (!ALLOWED_PROVIDERS.includes(req.params.provider)) {
+ return res.status(400).json({ error: 'Invalid provider' });
+ }
+ const result = await testSelectors(req.params.provider);
+ res.json(result);
+}));
+
+// === Message Detail Route (last to avoid capturing /launch, /selectors paths) ===
+const messageParamsSchema = z.object({
+ accountId: z.string().uuid(),
+ messageId: z.string().min(1)
+});
+
+router.get('/:accountId/:messageId', asyncHandler(async (req, res) => {
+ const parsed = messageParamsSchema.safeParse(req.params);
+ if (!parsed.success) return res.status(400).json({ error: 'Invalid accountId or messageId format' });
+ const message = await messageSync.getMessage(parsed.data.accountId, parsed.data.messageId);
+ if (!message) return res.status(404).json({ error: 'Message not found' });
+ res.json(message);
+}));
+
+export default router;
diff --git a/server/routes/messages.test.js b/server/routes/messages.test.js
new file mode 100644
index 00000000..b2227c02
--- /dev/null
+++ b/server/routes/messages.test.js
@@ -0,0 +1,606 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import express from 'express';
+import request from 'supertest';
+import messagesRoutes from './messages.js';
+
+// Mock the services
+vi.mock('../services/messageAccounts.js', () => ({
+ listAccounts: vi.fn(),
+ getAccount: vi.fn(),
+ createAccount: vi.fn(),
+ updateAccount: vi.fn(),
+ deleteAccount: vi.fn(),
+ updateSyncStatus: vi.fn()
+}));
+
+vi.mock('../services/messageSync.js', () => ({
+ syncAccount: vi.fn(),
+ getSyncStatus: vi.fn(),
+ getMessages: vi.fn(),
+ getMessage: vi.fn(),
+ deleteCache: vi.fn()
+}));
+
+vi.mock('../services/messageDrafts.js', () => ({
+ listDrafts: vi.fn(),
+ createDraft: vi.fn(),
+ updateDraft: vi.fn(),
+ approveDraft: vi.fn(),
+ deleteDraft: vi.fn(),
+ deleteDraftsByAccountId: vi.fn()
+}));
+
+vi.mock('../services/messageSender.js', () => ({
+ sendDraft: vi.fn()
+}));
+
+vi.mock('../services/messagePlaywrightSync.js', () => ({
+ getSelectors: vi.fn(),
+ updateSelectors: vi.fn(),
+ testSelectors: vi.fn(),
+ launchProvider: vi.fn()
+}));
+
+// Import mocked modules
+import * as messageAccounts from '../services/messageAccounts.js';
+import * as messageSync from '../services/messageSync.js';
+import * as messageDrafts from '../services/messageDrafts.js';
+import * as messageSender from '../services/messageSender.js';
+import * as messagePlaywrightSync from '../services/messagePlaywrightSync.js';
+
+const VALID_UUID = '11111111-1111-1111-1111-111111111111';
+const VALID_UUID_2 = '22222222-2222-2222-2222-222222222222';
+const DRAFT_UUID = '33333333-3333-3333-3333-333333333333';
+const DRAFT_UUID_2 = '44444444-4444-4444-4444-444444444444';
+const INVALID_UUID = 'not-a-uuid';
+
+describe('Messages Routes', () => {
+ let app;
+
+ beforeEach(() => {
+ app = express();
+ app.use(express.json());
+ app.use('/api/messages', messagesRoutes);
+ vi.clearAllMocks();
+ });
+
+ // === Account Routes ===
+
+ describe('GET /api/messages/accounts', () => {
+ it('should return list of accounts', async () => {
+ const mockAccounts = [
+ { id: VALID_UUID, name: 'Work Gmail', type: 'gmail' }
+ ];
+ messageAccounts.listAccounts.mockResolvedValue(mockAccounts);
+
+ const response = await request(app).get('/api/messages/accounts');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(mockAccounts);
+ });
+
+ it('should return empty array when no accounts exist', async () => {
+ messageAccounts.listAccounts.mockResolvedValue([]);
+
+ const response = await request(app).get('/api/messages/accounts');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveLength(0);
+ });
+ });
+
+ describe('POST /api/messages/accounts', () => {
+ it('should create a new account', async () => {
+ const newAccount = { name: 'Work Gmail', type: 'gmail', email: 'work@gmail.com' };
+ const created = { id: VALID_UUID, ...newAccount, enabled: true };
+ messageAccounts.createAccount.mockResolvedValue(created);
+
+ const response = await request(app)
+ .post('/api/messages/accounts')
+ .send(newAccount);
+
+ expect(response.status).toBe(201);
+ expect(response.body.id).toBe(VALID_UUID);
+ expect(messageAccounts.createAccount).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Work Gmail', type: 'gmail' })
+ );
+ });
+
+ it('should return 400 for missing name', async () => {
+ const response = await request(app)
+ .post('/api/messages/accounts')
+ .send({ type: 'gmail' });
+
+ expect(response.status).toBe(400);
+ });
+
+ it('should return 400 for invalid type', async () => {
+ const response = await request(app)
+ .post('/api/messages/accounts')
+ .send({ name: 'Test', type: 'yahoo' });
+
+ expect(response.status).toBe(400);
+ });
+ });
+
+ describe('PUT /api/messages/accounts/:id', () => {
+ it('should update an account', async () => {
+ const updated = { id: VALID_UUID, name: 'Updated', type: 'gmail' };
+ messageAccounts.updateAccount.mockResolvedValue(updated);
+
+ const response = await request(app)
+ .put(`/api/messages/accounts/${VALID_UUID}`)
+ .send({ name: 'Updated' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.name).toBe('Updated');
+ expect(messageAccounts.updateAccount).toHaveBeenCalledWith(VALID_UUID, { name: 'Updated' });
+ });
+
+ it('should return 400 for invalid UUID', async () => {
+ const response = await request(app)
+ .put(`/api/messages/accounts/${INVALID_UUID}`)
+ .send({ name: 'Updated' });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid account ID format');
+ });
+
+ it('should return 404 if account not found', async () => {
+ messageAccounts.updateAccount.mockResolvedValue(null);
+
+ const response = await request(app)
+ .put(`/api/messages/accounts/${VALID_UUID}`)
+ .send({ name: 'Updated' });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Account not found');
+ });
+ });
+
+ describe('DELETE /api/messages/accounts/:id', () => {
+ it('should delete an account', async () => {
+ messageAccounts.deleteAccount.mockResolvedValue(true);
+ messageSync.deleteCache.mockResolvedValue();
+ messageDrafts.deleteDraftsByAccountId.mockResolvedValue();
+
+ const response = await request(app).delete(`/api/messages/accounts/${VALID_UUID}`);
+
+ expect(response.status).toBe(204);
+ expect(messageAccounts.deleteAccount).toHaveBeenCalledWith(VALID_UUID);
+ expect(messageSync.deleteCache).toHaveBeenCalledWith(VALID_UUID);
+ expect(messageDrafts.deleteDraftsByAccountId).toHaveBeenCalledWith(VALID_UUID);
+ });
+
+ it('should return 400 for invalid UUID', async () => {
+ const response = await request(app).delete(`/api/messages/accounts/${INVALID_UUID}`);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid account ID format');
+ });
+
+ it('should return 404 if account not found', async () => {
+ messageAccounts.deleteAccount.mockResolvedValue(false);
+
+ const response = await request(app).delete(`/api/messages/accounts/${VALID_UUID}`);
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Account not found');
+ });
+ });
+
+ // === Sync Routes ===
+
+ describe('POST /api/messages/sync/:accountId', () => {
+ it('should trigger sync for an account', async () => {
+ messageSync.syncAccount.mockResolvedValue({ newMessages: 5, total: 100 });
+
+ const response = await request(app).post(`/api/messages/sync/${VALID_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.newMessages).toBe(5);
+ expect(messageSync.syncAccount).toHaveBeenCalledWith(VALID_UUID, undefined);
+ });
+
+ it('should return 400 for invalid UUID', async () => {
+ const response = await request(app).post(`/api/messages/sync/${INVALID_UUID}`);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid account ID format');
+ });
+
+ it('should return 404 if account not found', async () => {
+ messageSync.syncAccount.mockResolvedValue({ error: 'Account not found' });
+
+ const response = await request(app).post(`/api/messages/sync/${VALID_UUID}`);
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Account not found');
+ });
+ });
+
+ describe('GET /api/messages/sync/:accountId/status', () => {
+ it('should return sync status', async () => {
+ const status = { accountId: VALID_UUID, lastSyncAt: '2026-01-01T00:00:00Z', lastSyncStatus: 'success' };
+ messageSync.getSyncStatus.mockResolvedValue(status);
+
+ const response = await request(app).get(`/api/messages/sync/${VALID_UUID}/status`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.lastSyncStatus).toBe('success');
+ });
+
+ it('should return 404 if account not found', async () => {
+ messageSync.getSyncStatus.mockResolvedValue(null);
+
+ const response = await request(app).get(`/api/messages/sync/${VALID_UUID}/status`);
+
+ expect(response.status).toBe(404);
+ });
+ });
+
+ // === Inbox Routes ===
+
+ describe('GET /api/messages/inbox', () => {
+ it('should return messages', async () => {
+ const result = { messages: [{ id: 'msg-1', subject: 'Hello' }], total: 1 };
+ messageSync.getMessages.mockResolvedValue(result);
+
+ const response = await request(app).get('/api/messages/inbox');
+
+ expect(response.status).toBe(200);
+ expect(response.body.messages).toHaveLength(1);
+ expect(response.body.total).toBe(1);
+ });
+
+ it('should pass accountId filter', async () => {
+ messageSync.getMessages.mockResolvedValue({ messages: [], total: 0 });
+
+ await request(app).get(`/api/messages/inbox?accountId=${VALID_UUID}`);
+
+ expect(messageSync.getMessages).toHaveBeenCalledWith(
+ expect.objectContaining({ accountId: VALID_UUID })
+ );
+ });
+
+ it('should return 400 for invalid accountId format', async () => {
+ const response = await request(app).get(`/api/messages/inbox?accountId=${INVALID_UUID}`);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid accountId format');
+ });
+
+ it('should clamp limit to 100', async () => {
+ messageSync.getMessages.mockResolvedValue({ messages: [], total: 0 });
+
+ await request(app).get('/api/messages/inbox?limit=999');
+
+ expect(messageSync.getMessages).toHaveBeenCalledWith(
+ expect.objectContaining({ limit: 100 })
+ );
+ });
+
+ it('should default limit to 50 for invalid values', async () => {
+ messageSync.getMessages.mockResolvedValue({ messages: [], total: 0 });
+
+ await request(app).get('/api/messages/inbox?limit=abc');
+
+ expect(messageSync.getMessages).toHaveBeenCalledWith(
+ expect.objectContaining({ limit: 50 })
+ );
+ });
+ });
+
+ // === Draft Routes ===
+
+ describe('GET /api/messages/drafts', () => {
+ it('should return list of drafts', async () => {
+ const drafts = [{ id: DRAFT_UUID, subject: 'Re: Hello', status: 'draft' }];
+ messageDrafts.listDrafts.mockResolvedValue(drafts);
+
+ const response = await request(app).get('/api/messages/drafts');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveLength(1);
+ });
+
+ it('should pass accountId and status filters', async () => {
+ messageDrafts.listDrafts.mockResolvedValue([]);
+
+ await request(app).get(`/api/messages/drafts?accountId=${VALID_UUID}&status=draft`);
+
+ expect(messageDrafts.listDrafts).toHaveBeenCalledWith({
+ accountId: VALID_UUID,
+ status: 'draft'
+ });
+ });
+ });
+
+ describe('POST /api/messages/drafts', () => {
+ it('should create a draft', async () => {
+ const draftData = { accountId: VALID_UUID, subject: 'Test', body: 'Hello' };
+ const account = { id: VALID_UUID, type: 'gmail', provider: 'mcp' };
+ const created = { id: DRAFT_UUID, ...draftData, status: 'draft' };
+
+ messageAccounts.getAccount.mockResolvedValue(account);
+ messageDrafts.createDraft.mockResolvedValue(created);
+
+ const response = await request(app)
+ .post('/api/messages/drafts')
+ .send(draftData);
+
+ expect(response.status).toBe(201);
+ expect(response.body.id).toBe(DRAFT_UUID);
+ expect(messageDrafts.createDraft).toHaveBeenCalledWith(
+ expect.objectContaining({ accountId: VALID_UUID, sendVia: 'mcp' })
+ );
+ });
+
+ it('should return 404 if account not found', async () => {
+ messageAccounts.getAccount.mockResolvedValue(null);
+
+ const response = await request(app)
+ .post('/api/messages/drafts')
+ .send({ accountId: VALID_UUID });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Account not found');
+ });
+
+ it('should reject invalid accountId format', async () => {
+ const response = await request(app)
+ .post('/api/messages/drafts')
+ .send({ accountId: INVALID_UUID });
+
+ // validateRequest returns 400 for invalid input
+ expect(response.status).toBe(400);
+ });
+ });
+
+ describe('POST /api/messages/drafts/generate', () => {
+ it('should generate a draft', async () => {
+ const account = { id: VALID_UUID, type: 'outlook', provider: 'playwright' };
+ const created = { id: DRAFT_UUID, generatedBy: 'ai', status: 'draft' };
+
+ messageAccounts.getAccount.mockResolvedValue(account);
+ messageDrafts.createDraft.mockResolvedValue(created);
+
+ const response = await request(app)
+ .post('/api/messages/drafts/generate')
+ .send({ accountId: VALID_UUID, context: 'meeting follow-up' });
+
+ expect(response.status).toBe(201);
+ expect(response.body.generatedBy).toBe('ai');
+ });
+
+ it('should return 404 if account not found', async () => {
+ messageAccounts.getAccount.mockResolvedValue(null);
+
+ const response = await request(app)
+ .post('/api/messages/drafts/generate')
+ .send({ accountId: VALID_UUID });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Account not found');
+ });
+ });
+
+ describe('PUT /api/messages/drafts/:id', () => {
+ it('should update a draft', async () => {
+ const updated = { id: DRAFT_UUID, subject: 'Updated', status: 'draft' };
+ messageDrafts.updateDraft.mockResolvedValue(updated);
+
+ const response = await request(app)
+ .put(`/api/messages/drafts/${DRAFT_UUID}`)
+ .send({ subject: 'Updated' });
+
+ expect(response.status).toBe(200);
+ expect(response.body.subject).toBe('Updated');
+ });
+
+ it('should return 404 if draft not found', async () => {
+ messageDrafts.updateDraft.mockResolvedValue(null);
+
+ const response = await request(app)
+ .put(`/api/messages/drafts/${DRAFT_UUID_2}`)
+ .send({ subject: 'Updated' });
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Draft not found');
+ });
+ });
+
+ describe('POST /api/messages/drafts/:id/approve', () => {
+ it('should approve a draft', async () => {
+ const approved = { id: DRAFT_UUID, status: 'approved' };
+ messageDrafts.approveDraft.mockResolvedValue(approved);
+
+ const response = await request(app).post(`/api/messages/drafts/${DRAFT_UUID}/approve`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.status).toBe('approved');
+ });
+
+ it('should return 404 if draft not found', async () => {
+ messageDrafts.approveDraft.mockResolvedValue(null);
+
+ const response = await request(app).post(`/api/messages/drafts/${DRAFT_UUID_2}/approve`);
+
+ expect(response.status).toBe(404);
+ });
+ });
+
+ describe('POST /api/messages/drafts/:id/send', () => {
+ it('should send a draft', async () => {
+ messageSender.sendDraft.mockResolvedValue({ success: true });
+
+ const response = await request(app).post(`/api/messages/drafts/${DRAFT_UUID}/send`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.success).toBe(true);
+ });
+
+ it('should return 404 if draft not found', async () => {
+ messageSender.sendDraft.mockResolvedValue({ success: false, status: 404, code: 'DRAFT_NOT_FOUND', error: 'Draft not found' });
+
+ const response = await request(app).post(`/api/messages/drafts/${DRAFT_UUID_2}/send`);
+
+ expect(response.status).toBe(404);
+ });
+
+ it('should return 400 for non-not-found errors', async () => {
+ messageSender.sendDraft.mockResolvedValue({ success: false, status: 400, code: 'INVALID_STATUS', error: 'Draft not approved' });
+
+ const response = await request(app).post(`/api/messages/drafts/${DRAFT_UUID}/send`);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Draft not approved');
+ });
+ });
+
+ describe('DELETE /api/messages/drafts/:id', () => {
+ it('should delete a draft', async () => {
+ messageDrafts.deleteDraft.mockResolvedValue(true);
+
+ const response = await request(app).delete(`/api/messages/drafts/${DRAFT_UUID}`);
+
+ expect(response.status).toBe(204);
+ });
+
+ it('should return 404 if draft not found', async () => {
+ messageDrafts.deleteDraft.mockResolvedValue(false);
+
+ const response = await request(app).delete(`/api/messages/drafts/${DRAFT_UUID_2}`);
+
+ expect(response.status).toBe(404);
+ });
+ });
+
+ // === Message Detail Route ===
+
+ describe('GET /api/messages/:accountId/:messageId', () => {
+ it('should return a message', async () => {
+ const message = { id: 'msg-1', subject: 'Hello', accountId: VALID_UUID };
+ messageSync.getMessage.mockResolvedValue(message);
+
+ const response = await request(app).get(`/api/messages/${VALID_UUID}/msg-1`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.subject).toBe('Hello');
+ });
+
+ it('should return 400 for invalid accountId', async () => {
+ const response = await request(app).get(`/api/messages/${INVALID_UUID}/msg-1`);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid accountId or messageId format');
+ });
+
+ it('should return 404 if message not found', async () => {
+ messageSync.getMessage.mockResolvedValue(null);
+
+ const response = await request(app).get(`/api/messages/${VALID_UUID}/msg-1`);
+
+ expect(response.status).toBe(404);
+ expect(response.body.error).toBe('Message not found');
+ });
+ });
+
+ // === Browser Launch Route ===
+
+ describe('POST /api/messages/launch/:accountId', () => {
+ it('should launch browser for outlook account', async () => {
+ messageAccounts.getAccount.mockResolvedValue({ id: VALID_UUID, type: 'outlook' });
+ messagePlaywrightSync.launchProvider.mockResolvedValue({ success: true });
+
+ const response = await request(app).post(`/api/messages/launch/${VALID_UUID}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body.success).toBe(true);
+ expect(messagePlaywrightSync.launchProvider).toHaveBeenCalledWith('outlook');
+ });
+
+ it('should return 404 if account not found', async () => {
+ messageAccounts.getAccount.mockResolvedValue(null);
+
+ const response = await request(app).post(`/api/messages/launch/${VALID_UUID}`);
+
+ expect(response.status).toBe(404);
+ });
+
+ it('should return 400 for gmail accounts', async () => {
+ messageAccounts.getAccount.mockResolvedValue({ id: VALID_UUID, type: 'gmail' });
+
+ const response = await request(app).post(`/api/messages/launch/${VALID_UUID}`);
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Gmail uses MCP, not browser automation');
+ });
+
+ it('should return 503 if launch fails', async () => {
+ messageAccounts.getAccount.mockResolvedValue({ id: VALID_UUID, type: 'teams' });
+ messagePlaywrightSync.launchProvider.mockResolvedValue({ success: false, error: 'Browser not found' });
+
+ const response = await request(app).post(`/api/messages/launch/${VALID_UUID}`);
+
+ expect(response.status).toBe(503);
+ });
+ });
+
+ // === Selector Routes ===
+
+ describe('GET /api/messages/selectors', () => {
+ it('should return selectors', async () => {
+ const selectors = { outlook: { inbox: '.inbox' }, teams: { chat: '.chat' } };
+ messagePlaywrightSync.getSelectors.mockResolvedValue(selectors);
+
+ const response = await request(app).get('/api/messages/selectors');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(selectors);
+ });
+ });
+
+ describe('PUT /api/messages/selectors/:provider', () => {
+ it('should update selectors for a valid provider', async () => {
+ const updated = { inbox: '.new-inbox' };
+ messagePlaywrightSync.updateSelectors.mockResolvedValue(updated);
+
+ const response = await request(app)
+ .put('/api/messages/selectors/outlook')
+ .send({ selectors: { inbox: '.new-inbox' } });
+
+ expect(response.status).toBe(200);
+ expect(messagePlaywrightSync.updateSelectors).toHaveBeenCalledWith('outlook', { inbox: '.new-inbox' });
+ });
+
+ it('should return 400 for invalid provider', async () => {
+ const response = await request(app)
+ .put('/api/messages/selectors/gmail')
+ .send({ selectors: { inbox: '.inbox' } });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid provider');
+ });
+ });
+
+ describe('POST /api/messages/selectors/:provider/test', () => {
+ it('should test selectors for a valid provider', async () => {
+ messagePlaywrightSync.testSelectors.mockResolvedValue({ provider: 'teams', results: { matched: 5 }, status: 'ok' });
+
+ const response = await request(app).post('/api/messages/selectors/teams/test');
+
+ expect(response.status).toBe(200);
+ expect(response.body.status).toBe('ok');
+ expect(response.body.provider).toBe('teams');
+ expect(response.body.results).toEqual({ matched: 5 });
+ });
+
+ it('should return 400 for invalid provider', async () => {
+ const response = await request(app).post('/api/messages/selectors/gmail/test');
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('Invalid provider');
+ });
+ });
+});
diff --git a/server/services/messageAccounts.js b/server/services/messageAccounts.js
new file mode 100644
index 00000000..eadab7a9
--- /dev/null
+++ b/server/services/messageAccounts.js
@@ -0,0 +1,84 @@
+import { readFile, writeFile } from 'fs/promises';
+import { join } from 'path';
+import { v4 as uuidv4 } from 'uuid';
+import { ensureDir, PATHS, safeJSONParse } from '../lib/fileUtils.js';
+
+const ACCOUNTS_FILE = join(PATHS.messages, 'accounts.json');
+
+async function loadAccounts() {
+ await ensureDir(PATHS.messages);
+ const content = await readFile(ACCOUNTS_FILE, 'utf-8').catch(() => null);
+ if (!content) return {};
+ const parsed = safeJSONParse(content, {}, { context: 'messageAccounts' });
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
+}
+
+async function saveAccounts(accounts) {
+ await ensureDir(PATHS.messages);
+ await writeFile(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2));
+}
+
+export async function listAccounts() {
+ const accounts = await loadAccounts();
+ return Object.values(accounts).sort((a, b) => a.name.localeCompare(b.name));
+}
+
+export async function getAccount(id) {
+ const accounts = await loadAccounts();
+ return accounts[id] || null;
+}
+
+export async function createAccount(data) {
+ const accounts = await loadAccounts();
+ const id = uuidv4();
+ accounts[id] = {
+ id,
+ name: data.name,
+ type: data.type, // gmail, outlook, teams
+ provider: data.type === 'gmail' ? 'mcp' : 'playwright',
+ email: data.email || '',
+ enabled: true,
+ syncConfig: {
+ maxAge: data.syncConfig?.maxAge || '30d',
+ maxMessages: data.syncConfig?.maxMessages || 500,
+ syncInterval: data.syncConfig?.syncInterval || 300000
+ },
+ lastSyncAt: null,
+ lastSyncStatus: null,
+ createdAt: new Date().toISOString()
+ };
+ await saveAccounts(accounts);
+ console.log(`📧 Message account created: ${data.name} (${data.type})`);
+ return accounts[id];
+}
+
+export async function updateAccount(id, updates) {
+ const accounts = await loadAccounts();
+ if (!accounts[id]) return null;
+ const { name, email, enabled, syncConfig } = updates;
+ if (name !== undefined) accounts[id].name = name;
+ if (email !== undefined) accounts[id].email = email;
+ if (enabled !== undefined) accounts[id].enabled = enabled;
+ if (syncConfig) accounts[id].syncConfig = { ...accounts[id].syncConfig, ...syncConfig };
+ accounts[id].updatedAt = new Date().toISOString();
+ await saveAccounts(accounts);
+ return accounts[id];
+}
+
+export async function deleteAccount(id) {
+ const accounts = await loadAccounts();
+ if (!accounts[id]) return false;
+ delete accounts[id];
+ await saveAccounts(accounts);
+ console.log(`🗑️ Message account deleted: ${id}`);
+ return true;
+}
+
+export async function updateSyncStatus(id, status) {
+ const accounts = await loadAccounts();
+ if (!accounts[id]) return null;
+ accounts[id].lastSyncAt = new Date().toISOString();
+ accounts[id].lastSyncStatus = status;
+ await saveAccounts(accounts);
+ return accounts[id];
+}
diff --git a/server/services/messageDrafts.js b/server/services/messageDrafts.js
new file mode 100644
index 00000000..cd0be577
--- /dev/null
+++ b/server/services/messageDrafts.js
@@ -0,0 +1,90 @@
+import { readFile, writeFile } from 'fs/promises';
+import { join } from 'path';
+import { v4 as uuidv4 } from 'uuid';
+import { ensureDir, PATHS, safeJSONParse } from '../lib/fileUtils.js';
+
+const DRAFTS_FILE = join(PATHS.messages, 'drafts.json');
+
+async function loadDrafts() {
+ await ensureDir(PATHS.messages);
+ const content = await readFile(DRAFTS_FILE, 'utf-8').catch(() => null);
+ if (!content) return [];
+ const parsed = safeJSONParse(content, [], { context: 'messageDrafts' });
+ return Array.isArray(parsed) ? parsed : [];
+}
+
+async function saveDrafts(drafts) {
+ await ensureDir(PATHS.messages);
+ await writeFile(DRAFTS_FILE, JSON.stringify(drafts, null, 2));
+}
+
+export async function listDrafts(filters = {}) {
+ let drafts = await loadDrafts();
+ if (filters.accountId) drafts = drafts.filter(d => d.accountId === filters.accountId);
+ if (filters.status) drafts = drafts.filter(d => d.status === filters.status);
+ return drafts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+}
+
+export async function getDraft(id) {
+ const drafts = await loadDrafts();
+ return drafts.find(d => d.id === id) || null;
+}
+
+export async function createDraft(data) {
+ const drafts = await loadDrafts();
+ const draft = {
+ id: uuidv4(),
+ accountId: data.accountId,
+ replyToMessageId: data.replyToMessageId || null,
+ threadId: data.threadId || null,
+ to: data.to || [],
+ cc: data.cc || [],
+ subject: data.subject || '',
+ body: data.body || '',
+ status: 'draft',
+ generatedBy: data.generatedBy || 'manual',
+ sendVia: data.sendVia || 'mcp',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ };
+ drafts.push(draft);
+ await saveDrafts(drafts);
+ console.log(`📝 Message draft created: "${draft.subject}" via ${draft.sendVia}`);
+ return draft;
+}
+
+export async function updateDraft(id, updates) {
+ const drafts = await loadDrafts();
+ const idx = drafts.findIndex(d => d.id === id);
+ if (idx === -1) return null;
+ const allowed = ['to', 'cc', 'subject', 'body', 'status'];
+ for (const key of allowed) {
+ if (updates[key] !== undefined) drafts[idx][key] = updates[key];
+ }
+ drafts[idx].updatedAt = new Date().toISOString();
+ await saveDrafts(drafts);
+ return drafts[idx];
+}
+
+export async function approveDraft(id) {
+ return updateDraft(id, { status: 'approved' });
+}
+
+export async function deleteDraftsByAccountId(accountId) {
+ const drafts = await loadDrafts();
+ const remaining = drafts.filter(d => d.accountId !== accountId);
+ if (remaining.length < drafts.length) {
+ await saveDrafts(remaining);
+ console.log(`🗑️ Deleted ${drafts.length - remaining.length} drafts for account ${accountId}`);
+ }
+}
+
+export async function deleteDraft(id) {
+ const drafts = await loadDrafts();
+ const idx = drafts.findIndex(d => d.id === id);
+ if (idx === -1) return false;
+ drafts.splice(idx, 1);
+ await saveDrafts(drafts);
+ console.log(`🗑️ Message draft deleted: ${id}`);
+ return true;
+}
diff --git a/server/services/messageGmailSync.js b/server/services/messageGmailSync.js
new file mode 100644
index 00000000..9ee83d52
--- /dev/null
+++ b/server/services/messageGmailSync.js
@@ -0,0 +1,21 @@
+/**
+ * Sync Gmail messages via MCP
+ * This is a stub that returns an empty array until Gmail MCP is configured.
+ * When MCP is available, this will call the Gmail API through the MCP bridge.
+ */
+export async function syncGmail(account, cache, io) {
+ console.log(`📧 Gmail sync for ${account.email} — MCP integration pending`);
+ io?.emit('messages:sync:progress', { accountId: account.id, current: 0, total: 0 });
+ // TODO: Integrate with Gmail MCP when available
+ // The MCP bridge would call gmail.users.messages.list and gmail.users.messages.get
+ return { messages: [], status: 'not-configured' };
+}
+
+/**
+ * Send email via Gmail MCP
+ */
+export async function sendGmail(account, draft) {
+ console.log(`📧 Gmail send for ${account.email} — MCP integration pending`);
+ // TODO: Integrate with Gmail MCP when available
+ return { success: false, error: 'Gmail MCP not configured', status: 501, code: 'GMAIL_MCP_NOT_CONFIGURED' };
+}
diff --git a/server/services/messagePlaywrightSync.js b/server/services/messagePlaywrightSync.js
new file mode 100644
index 00000000..38b964ce
--- /dev/null
+++ b/server/services/messagePlaywrightSync.js
@@ -0,0 +1,254 @@
+import { readFile, writeFile } from 'fs/promises';
+import { join } from 'path';
+import crypto from 'crypto';
+import { v4 as uuidv4 } from 'uuid';
+import { ensureDir, PATHS, safeJSONParse } from '../lib/fileUtils.js';
+import { loadConfig } from './browserService.js';
+
+const SELECTORS_FILE = join(PATHS.messages, 'selectors.json');
+
+const OUTLOOK_URL = 'https://outlook.office.com/mail/';
+const TEAMS_URL = 'https://teams.microsoft.com/';
+
+// Auth detection patterns in page titles/URLs
+const AUTH_PATTERNS = ['login.microsoftonline.com', 'okta.com', 'login.live.com', 'Sign in'];
+
+function makeExternalId(date, sender, subject) {
+ const hash = crypto.createHash('md5')
+ .update(`${date}|${sender}|${subject}`)
+ .digest('hex')
+ .slice(0, 12);
+ return `pw-${hash}`;
+}
+
+// TODO: Extract shared CDP helpers (getCdpConnectHost, cdpFetch, getPages, findOrOpenPage)
+// into browserService.js to avoid duplication with existing CDP logic there
+async function getCdpConnectHost() {
+ const config = await loadConfig();
+ const host = (config.cdpHost === '0.0.0.0' || config.cdpHost === '::') ? '127.0.0.1' : config.cdpHost;
+ return { host, port: config.cdpPort };
+}
+
+async function cdpFetch(path, options = {}) {
+ const { host, port } = await getCdpConnectHost();
+ const url = `http://${host}:${port}${path}`;
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), options.timeout || 10000);
+ const response = await fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timeout));
+ return response;
+}
+
+async function getPages() {
+ const response = await cdpFetch('/json/list');
+ if (!response.ok) return [];
+ return response.json();
+}
+
+async function findOrOpenPage(targetUrl) {
+ const pages = await getPages();
+ // Find existing tab matching the target
+ const existing = pages.find(p => p.url?.includes(new URL(targetUrl).hostname));
+ if (existing) return existing;
+ // Open new tab
+ const response = await cdpFetch(`/json/new?${encodeURIComponent(targetUrl)}`, { method: 'PUT' });
+ if (!response.ok) return null;
+ return response.json();
+}
+
+function isAuthPage(page) {
+ const url = page.url || '';
+ const title = page.title || '';
+ return AUTH_PATTERNS.some(p => url.includes(p) || title.includes(p));
+}
+
+async function evaluateOnPage(page, expression) {
+ const wsUrl = page.webSocketDebuggerUrl;
+ if (!wsUrl) return null;
+
+ const { default: WebSocket } = await import('ws');
+
+ return new Promise((resolve) => {
+ const ws = new WebSocket(wsUrl);
+ const timer = setTimeout(() => { ws.close(); resolve(null); }, 15000);
+
+ ws.on('open', () => {
+ ws.send(JSON.stringify({
+ id: 1,
+ method: 'Runtime.evaluate',
+ params: { expression, returnByValue: true, awaitPromise: true }
+ }));
+ });
+
+ ws.on('message', (data) => {
+ const msg = safeJSONParse(data.toString(), null, { context: 'cdp-ws' });
+ if (!msg || msg.id !== 1) return;
+ clearTimeout(timer);
+ ws.close();
+ if (msg.error || msg.result?.exceptionDetails) return resolve(null);
+ resolve(msg.result?.result?.value ?? null);
+ });
+
+ ws.on('error', () => { clearTimeout(timer); ws.close(); resolve(null); });
+ });
+}
+
+export async function getSelectors() {
+ const content = await readFile(SELECTORS_FILE, 'utf-8').catch(() => null);
+ if (!content) return {};
+ const parsed = safeJSONParse(content, {}, { context: 'messageSelectors' });
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
+}
+
+export async function updateSelectors(provider, selectors) {
+ const all = await getSelectors();
+ all[provider] = selectors;
+ await ensureDir(PATHS.messages);
+ await writeFile(SELECTORS_FILE, JSON.stringify(all, null, 2));
+ return all[provider];
+}
+
+/**
+ * Open the provider's web app in the CDP browser for login
+ */
+export async function launchProvider(accountType) {
+ const url = accountType === 'teams' ? TEAMS_URL : OUTLOOK_URL;
+ const page = await findOrOpenPage(url).catch(() => null);
+ if (!page) return { success: false, error: 'Failed to open browser tab — is portos-browser running?' };
+ console.log(`📧 Launched ${accountType} in CDP browser: ${page.url}`);
+ return { success: true, url: page.url, pageId: page.id, title: page.title };
+}
+
+/**
+ * Sync messages via CDP browser automation
+ * Connects to the portos-browser CDP instance, finds the provider page,
+ * and scrapes messages using DOM evaluation
+ */
+export async function syncPlaywright(account, cache, io) {
+ const targetUrl = account.type === 'teams' ? TEAMS_URL : OUTLOOK_URL;
+ console.log(`📧 Playwright sync for ${account.email} (${account.type})`);
+
+ // Find the provider page in CDP browser
+ const page = await findOrOpenPage(targetUrl).catch(() => null);
+ if (!page) {
+ io?.emit('messages:sync:progress', { accountId: account.id, current: 0, total: 0 });
+ console.log(`📧 No CDP browser available — launch browser first`);
+ return { messages: [], status: 'no-browser' };
+ }
+
+ // Check for auth/login page
+ if (isAuthPage(page)) {
+ console.log(`📧 Auth required for ${account.type} — login page detected`);
+ io?.emit('messages:sync:auth-required', { accountId: account.id });
+ return { messages: [], status: 'auth-required' };
+ }
+
+ // Load selectors for this provider
+ const allSelectors = await getSelectors();
+ const sels = allSelectors[account.type] || {};
+
+ // Use CDP Runtime.evaluate to extract messages from the page DOM
+ const extractScript = buildExtractionScript(account.type, sels);
+ const extracted = await evaluateOnPage(page, extractScript);
+
+ if (!extracted || !Array.isArray(extracted)) {
+ console.log(`📧 No messages extracted from ${account.type} page`);
+ io?.emit('messages:sync:progress', { accountId: account.id, current: 0, total: 0 });
+ return { messages: [], status: 'extraction-failed' };
+ }
+
+ io?.emit('messages:sync:progress', { accountId: account.id, current: extracted.length, total: extracted.length });
+
+ // Convert extracted data to message format
+ const messages = extracted.map(msg => ({
+ id: uuidv4(),
+ externalId: makeExternalId(msg.date || '', msg.from || '', msg.subject || ''),
+ threadId: null,
+ from: { name: msg.from || '', email: msg.fromEmail || '' },
+ to: [],
+ cc: [],
+ subject: msg.subject || '',
+ bodyText: msg.preview || '',
+ date: msg.date || new Date().toISOString(),
+ isRead: msg.isRead ?? true,
+ labels: [],
+ source: account.type,
+ syncedAt: new Date().toISOString()
+ }));
+ return { messages, status: 'success' };
+}
+
+function buildExtractionScript(type, sels) {
+ if (type === 'outlook') {
+ const listSel = sels.messageRow || "[role='listitem']";
+ return `
+ (function() {
+ const rows = document.querySelectorAll(${JSON.stringify(listSel)});
+ return Array.from(rows).slice(0, 50).map(row => {
+ const text = row.innerText || '';
+ const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
+ return {
+ from: lines[0] || '',
+ subject: lines[1] || '',
+ preview: lines[2] || '',
+ date: lines[3] || '',
+ isRead: !row.querySelector('[aria-label*="Unread"]')
+ };
+ });
+ })()
+ `;
+ }
+ if (type === 'teams') {
+ const msgSel = sels.messageItem || "[role='listitem']";
+ return `
+ (function() {
+ const items = document.querySelectorAll(${JSON.stringify(msgSel)});
+ return Array.from(items).slice(0, 50).map(item => {
+ const text = item.innerText || '';
+ const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
+ return {
+ from: lines[0] || '',
+ subject: '',
+ preview: lines[1] || '',
+ date: lines[2] || '',
+ isRead: true
+ };
+ });
+ })()
+ `;
+ }
+ return '[]';
+}
+
+/**
+ * Send message via Playwright browser automation
+ */
+export async function sendPlaywright(account, draft) {
+ console.log(`📧 Playwright send for ${account.email} (${account.type}) — automation pending`);
+ return { success: false, error: 'Playwright send not yet implemented', status: 501, code: 'NOT_IMPLEMENTED' };
+}
+
+/**
+ * Test selectors against the current page
+ */
+export async function testSelectors(provider) {
+ const targetUrl = provider === 'teams' ? TEAMS_URL : OUTLOOK_URL;
+ const pages = await getPages().catch(() => []);
+ const page = pages.find(p => p.url?.includes(new URL(targetUrl).hostname));
+ if (!page) return { provider, results: {}, status: 'no_page', error: 'No browser tab open for this provider' };
+
+ const allSelectors = await getSelectors();
+ const sels = allSelectors[provider] || {};
+ const results = {};
+
+ for (const [name, selector] of Object.entries(sels)) {
+ const count = await evaluateOnPage(page,
+ `document.querySelectorAll(${JSON.stringify(selector)}).length`
+ );
+ results[name] = { selector, matches: count ?? 0 };
+ }
+
+ const entries = Object.values(results);
+ const status = entries.length === 0 ? 'no-selectors' : entries.every(r => r.matches > 0) ? 'ok' : 'partial';
+ console.log(`📧 Selector test for ${provider}: ${status}`);
+ return { provider, results, status };
+}
diff --git a/server/services/messageSender.js b/server/services/messageSender.js
new file mode 100644
index 00000000..6c224aa3
--- /dev/null
+++ b/server/services/messageSender.js
@@ -0,0 +1,53 @@
+import { getDraft, updateDraft } from './messageDrafts.js';
+import { getAccount } from './messageAccounts.js';
+
+const ACCOUNT_TYPE_TO_SEND_VIA = {
+ gmail: 'mcp',
+ outlook: 'playwright',
+ teams: 'playwright'
+};
+
+export async function sendDraft(draftId, io) {
+ const draft = await getDraft(draftId);
+ if (!draft) return { success: false, status: 404, code: 'DRAFT_NOT_FOUND', error: 'Draft not found' };
+ if (draft.status !== 'approved') return { success: false, status: 400, code: 'INVALID_STATUS', error: `Draft status is "${draft.status}", must be "approved"` };
+
+ const account = await getAccount(draft.accountId);
+ if (!account) return { success: false, status: 404, code: 'ACCOUNT_NOT_FOUND', error: 'Account not found' };
+
+ const expectedSendVia = ACCOUNT_TYPE_TO_SEND_VIA[account.type];
+ if (draft.sendVia !== expectedSendVia) {
+ return { success: false, status: 400, code: 'SEND_VIA_MISMATCH', error: `sendVia "${draft.sendVia}" does not match account type "${account.type}" (expected "${expectedSendVia}")` };
+ }
+
+ await updateDraft(draftId, { status: 'sending' });
+ console.log(`📧 Sending draft "${draft.subject}" via ${draft.sendVia}`);
+
+ const dispatch = async () => {
+ if (draft.sendVia === 'mcp') {
+ const { sendGmail } = await import('./messageGmailSync.js');
+ return sendGmail(account, draft);
+ }
+ const { sendPlaywright } = await import('./messagePlaywrightSync.js');
+ return sendPlaywright(account, draft);
+ };
+
+ const result = await dispatch().catch(async (error) => {
+ console.error(`📧 Draft send threw for "${draft.subject}": ${error.message}`);
+ return { success: false, status: 502, code: 'SEND_FAILED', error: error.message };
+ });
+
+ if (result?.success) {
+ await updateDraft(draftId, { status: 'sent' });
+ io?.emit('messages:draft:sent', { draftId });
+ io?.emit('messages:changed', {});
+ console.log(`📧 Draft sent successfully: "${draft.subject}"`);
+ } else {
+ await updateDraft(draftId, { status: 'failed' }).catch(() => {});
+ const errorMsg = result?.error ?? 'Unknown error sending draft';
+ console.log(`📧 Draft send failed: ${errorMsg}`);
+ return { success: false, status: result?.status ?? 500, code: result?.code ?? 'SEND_FAILED', error: errorMsg };
+ }
+
+ return result;
+}
diff --git a/server/services/messageSync.js b/server/services/messageSync.js
new file mode 100644
index 00000000..7bb2211f
--- /dev/null
+++ b/server/services/messageSync.js
@@ -0,0 +1,170 @@
+import { readFile, writeFile } from 'fs/promises';
+import { join } from 'path';
+import { ensureDir, PATHS, safeJSONParse } from '../lib/fileUtils.js';
+import { getAccount, updateSyncStatus } from './messageAccounts.js';
+
+const CACHE_DIR = join(PATHS.messages, 'cache');
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const syncLocks = new Map();
+
+function safeDate(d) {
+ const t = new Date(d).getTime();
+ return Number.isNaN(t) ? 0 : t;
+}
+
+function filterBySearch(messages, search) {
+ if (!search) return messages;
+ const q = search.toLowerCase();
+ return messages.filter(m =>
+ m.subject?.toLowerCase().includes(q) ||
+ m.from?.name?.toLowerCase().includes(q) ||
+ m.from?.email?.toLowerCase().includes(q) ||
+ m.bodyText?.toLowerCase().includes(q)
+ );
+}
+
+async function loadCache(accountId) {
+ if (!UUID_RE.test(accountId)) throw new Error(`Invalid accountId: ${accountId}`);
+ await ensureDir(CACHE_DIR);
+ const filePath = join(CACHE_DIR, `${accountId}.json`);
+ const content = await readFile(filePath, 'utf-8').catch(() => null);
+ if (!content) return { syncCursor: null, messages: [] };
+ const parsed = safeJSONParse(content, { syncCursor: null, messages: [] }, { context: `messageCache:${accountId}` });
+ if (!parsed || !Array.isArray(parsed.messages)) return { syncCursor: null, messages: [] };
+ return parsed;
+}
+
+async function saveCache(accountId, cache) {
+ await ensureDir(CACHE_DIR);
+ const filePath = join(CACHE_DIR, `${accountId}.json`);
+ await writeFile(filePath, JSON.stringify(cache, null, 2));
+}
+
+export async function getMessages(options = {}) {
+ const { accountId, search, limit = 50, offset = 0 } = options;
+ // If specific account, just load that cache
+ if (accountId) {
+ const cache = await loadCache(accountId);
+ let messages = cache.messages.map(m => ({ ...m, accountId: m.accountId || accountId }));
+ messages = filterBySearch(messages, search);
+ return {
+ messages: messages.sort((a, b) => safeDate(b.date) - safeDate(a.date)).slice(offset, offset + limit),
+ total: messages.length
+ };
+ }
+
+ // Otherwise aggregate across all account caches
+ await ensureDir(CACHE_DIR);
+ const { readdir } = await import('fs/promises');
+ const files = await readdir(CACHE_DIR).catch(() => []);
+ let allMessages = [];
+ for (const file of files) {
+ if (!file.endsWith('.json')) continue;
+ const fileAccountId = file.replace('.json', '');
+ if (!UUID_RE.test(fileAccountId)) continue;
+ const cache = await loadCache(fileAccountId);
+ allMessages.push(...cache.messages.map(m => ({ ...m, accountId: m.accountId || fileAccountId })));
+ }
+ allMessages = filterBySearch(allMessages, search);
+ allMessages.sort((a, b) => safeDate(b.date) - safeDate(a.date));
+ return {
+ messages: allMessages.slice(offset, offset + limit),
+ total: allMessages.length
+ };
+}
+
+export async function deleteCache(accountId) {
+ if (!UUID_RE.test(accountId)) return;
+ const { unlink } = await import('fs/promises');
+ const filePath = join(CACHE_DIR, `${accountId}.json`);
+ try {
+ await unlink(filePath);
+ console.log(`🗑️ Message cache deleted for account ${accountId}`);
+ } catch (err) {
+ if (err.code === 'ENOENT') {
+ console.log(`🗑️ No message cache to delete for account ${accountId}`);
+ } else {
+ console.error(`❌ Failed to delete message cache for account ${accountId}: ${err.message}`);
+ }
+ }
+}
+
+export async function getMessage(accountId, messageId) {
+ const cache = await loadCache(accountId);
+ const msg = cache.messages.find(m => m.id === messageId);
+ if (!msg) return null;
+ return { ...msg, accountId: msg.accountId || accountId };
+}
+
+export async function syncAccount(accountId, io) {
+ if (syncLocks.has(accountId)) return { error: 'Sync already in progress', status: 409 };
+
+ const account = await getAccount(accountId);
+ if (!account) return { error: 'Account not found' };
+ if (!account.enabled) return { error: 'Account is disabled', status: 400 };
+
+ syncLocks.set(accountId, true);
+ io?.emit('messages:sync:started', { accountId });
+ console.log(`📧 Starting sync for ${account.name} (${account.type})`);
+
+ const providerSync = async () => {
+ const cache = await loadCache(accountId);
+ let providerResult;
+ if (account.type === 'gmail') {
+ const { syncGmail } = await import('./messageGmailSync.js');
+ providerResult = await syncGmail(account, cache, io);
+ } else if (account.type === 'outlook' || account.type === 'teams') {
+ const { syncPlaywright } = await import('./messagePlaywrightSync.js');
+ providerResult = await syncPlaywright(account, cache, io);
+ } else {
+ throw new Error(`Unsupported account type: ${account.type}`);
+ }
+
+ // Support structured result { messages, status } or plain array
+ const newMessages = Array.isArray(providerResult) ? providerResult : providerResult?.messages ?? [];
+ const providerStatus = Array.isArray(providerResult) ? 'success' : providerResult?.status ?? 'success';
+
+ // Deduplicate by externalId (skip dedup for messages without externalId)
+ const existingIds = new Set(cache.messages.map(m => m.externalId).filter(Boolean));
+ const uniqueNew = newMessages.filter(m => !m.externalId || !existingIds.has(m.externalId));
+ cache.messages.push(...uniqueNew);
+
+ // Trim to maxMessages
+ if (account.syncConfig?.maxMessages && cache.messages.length > account.syncConfig.maxMessages) {
+ cache.messages.sort((a, b) => safeDate(b.date) - safeDate(a.date));
+ cache.messages = cache.messages.slice(0, account.syncConfig.maxMessages);
+ }
+
+ await saveCache(accountId, cache);
+ await updateSyncStatus(accountId, providerStatus === 'success' ? 'success' : providerStatus);
+
+ io?.emit('messages:sync:completed', { accountId, newMessages: uniqueNew.length, status: providerStatus });
+ if (providerStatus === 'success') {
+ io?.emit('messages:changed', {});
+ }
+ console.log(`📧 Sync complete for ${account.name}: ${uniqueNew.length} new, status=${providerStatus}`);
+
+ return { newMessages: uniqueNew.length, total: cache.messages.length, status: providerStatus };
+ };
+
+ const result = await providerSync().catch(async (error) => {
+ console.error(`📧 Sync failed for ${account.name} (${account.type}): ${error.message}`);
+ await updateSyncStatus(accountId, 'error').catch(() => {});
+ io?.emit('messages:sync:failed', { accountId, error: error.message });
+ return { error: error.message, status: 502 };
+ }).finally(() => {
+ syncLocks.delete(accountId);
+ });
+
+ return result;
+}
+
+export async function getSyncStatus(accountId) {
+ const account = await getAccount(accountId);
+ if (!account) return null;
+ return {
+ accountId,
+ lastSyncAt: account.lastSyncAt,
+ lastSyncStatus: account.lastSyncStatus
+ };
+}
diff --git a/server/services/messageSync.test.js b/server/services/messageSync.test.js
new file mode 100644
index 00000000..364f3a1c
--- /dev/null
+++ b/server/services/messageSync.test.js
@@ -0,0 +1,492 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock fs/promises before importing the module
+vi.mock('fs/promises', () => ({
+ readFile: vi.fn(),
+ writeFile: vi.fn(),
+ readdir: vi.fn(),
+ unlink: vi.fn()
+}));
+
+vi.mock('../lib/fileUtils.js', () => ({
+ ensureDir: vi.fn(),
+ PATHS: { messages: '/mock/data/messages' },
+ safeJSONParse: vi.fn((content, fallback) => {
+ if (!content) return fallback;
+ const parsed = JSON.parse(content);
+ return parsed;
+ })
+}));
+
+vi.mock('./messageAccounts.js', () => ({
+ getAccount: vi.fn(),
+ updateSyncStatus: vi.fn()
+}));
+
+vi.mock('./messageGmailSync.js', () => ({
+ syncGmail: vi.fn()
+}));
+
+vi.mock('./messagePlaywrightSync.js', () => ({
+ syncPlaywright: vi.fn()
+}));
+
+import { readFile, writeFile, readdir, unlink } from 'fs/promises';
+import { getMessages, getMessage, syncAccount, deleteCache, getSyncStatus } from './messageSync.js';
+import { getAccount, updateSyncStatus } from './messageAccounts.js';
+import { syncGmail } from './messageGmailSync.js';
+import { syncPlaywright } from './messagePlaywrightSync.js';
+
+const VALID_UUID = '11111111-1111-1111-1111-111111111111';
+const VALID_UUID_2 = '22222222-2222-2222-2222-222222222222';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ // Default: cache file not found
+ readFile.mockRejectedValue(new Error('ENOENT'));
+ writeFile.mockResolvedValue();
+});
+
+// ─── Cache I/O: getMessages ───
+
+describe('getMessages', () => {
+ it('should return empty messages when cache file does not exist', async () => {
+ const result = await getMessages({ accountId: VALID_UUID });
+ expect(result.messages).toEqual([]);
+ expect(result.total).toBe(0);
+ });
+
+ it('should load and return messages from cache for a specific account', async () => {
+ const cache = {
+ syncCursor: 'cur-1',
+ messages: [
+ { id: 'msg-1', subject: 'Hello', date: '2026-01-02T00:00:00Z', externalId: 'ext-1' },
+ { id: 'msg-2', subject: 'World', date: '2026-01-01T00:00:00Z', externalId: 'ext-2' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessages({ accountId: VALID_UUID });
+
+ expect(result.messages).toHaveLength(2);
+ expect(result.total).toBe(2);
+ // Should be sorted newest first
+ expect(result.messages[0].subject).toBe('Hello');
+ expect(result.messages[1].subject).toBe('World');
+ });
+
+ it('should stamp accountId onto messages', async () => {
+ const cache = { messages: [{ id: 'msg-1', subject: 'Test' }] };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessages({ accountId: VALID_UUID });
+ expect(result.messages[0].accountId).toBe(VALID_UUID);
+ });
+
+ it('should apply search filter', async () => {
+ const cache = {
+ messages: [
+ { id: 'msg-1', subject: 'Meeting Notes', date: '2026-01-01' },
+ { id: 'msg-2', subject: 'Invoice', date: '2026-01-01' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessages({ accountId: VALID_UUID, search: 'meeting' });
+ expect(result.messages).toHaveLength(1);
+ expect(result.total).toBe(1);
+ });
+
+ it('should filter by from.name', async () => {
+ const cache = {
+ messages: [
+ { id: 'msg-1', from: { name: 'Alice' }, date: '2026-01-01' },
+ { id: 'msg-2', from: { name: 'Bob' }, date: '2026-01-01' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessages({ accountId: VALID_UUID, search: 'alice' });
+ expect(result.messages).toHaveLength(1);
+ expect(result.messages[0].id).toBe('msg-1');
+ });
+
+ it('should filter by from.email', async () => {
+ const cache = {
+ messages: [
+ { id: 'msg-1', from: { email: 'alice@test.com' }, date: '2026-01-01' },
+ { id: 'msg-2', from: { email: 'bob@test.com' }, date: '2026-01-01' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessages({ accountId: VALID_UUID, search: 'alice@' });
+ expect(result.messages).toHaveLength(1);
+ expect(result.messages[0].id).toBe('msg-1');
+ });
+
+ it('should filter by bodyText', async () => {
+ const cache = {
+ messages: [
+ { id: 'msg-1', bodyText: 'Hello world', date: '2026-01-01' },
+ { id: 'msg-2', bodyText: 'Goodbye', date: '2026-01-01' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessages({ accountId: VALID_UUID, search: 'hello' });
+ expect(result.messages).toHaveLength(1);
+ expect(result.messages[0].id).toBe('msg-1');
+ });
+
+ it('should handle search with missing fields gracefully', async () => {
+ const cache = {
+ messages: [
+ { id: 'msg-1', date: '2026-01-01' },
+ { id: 'msg-2', subject: 'Test', date: '2026-01-01' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessages({ accountId: VALID_UUID, search: 'test' });
+ expect(result.messages).toHaveLength(1);
+ expect(result.messages[0].id).toBe('msg-2');
+ });
+
+ it('should sort messages with invalid dates to the end', async () => {
+ const cache = {
+ messages: [
+ { id: 'msg-bad', subject: 'Bad Date', date: 'not-a-date' },
+ { id: 'msg-good', subject: 'Good Date', date: '2026-01-15T10:00:00Z' },
+ { id: 'msg-null', subject: 'Null Date' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessages({ accountId: VALID_UUID });
+ expect(result.messages).toHaveLength(3);
+ // Valid date should come first (newest first), invalid dates sort to end (timestamp 0)
+ expect(result.messages[0].id).toBe('msg-good');
+ });
+
+ it('should apply offset and limit', async () => {
+ const msgs = Array.from({ length: 10 }, (_, i) => ({
+ id: `msg-${i}`, subject: `Msg ${i}`, date: `2026-01-${String(i + 1).padStart(2, '0')}`
+ }));
+ readFile.mockResolvedValue(JSON.stringify({ messages: msgs }));
+
+ const result = await getMessages({ accountId: VALID_UUID, limit: 3, offset: 2 });
+ expect(result.messages).toHaveLength(3);
+ expect(result.total).toBe(10);
+ });
+
+ it('should aggregate across all account caches when no accountId', async () => {
+ readdir.mockResolvedValue([`${VALID_UUID}.json`, `${VALID_UUID_2}.json`, 'not-uuid.json']);
+ readFile.mockImplementation((filePath) => {
+ if (filePath.includes(VALID_UUID_2)) {
+ return Promise.resolve(JSON.stringify({
+ messages: [{ id: 'msg-b', subject: 'From B', date: '2026-01-02' }]
+ }));
+ }
+ return Promise.resolve(JSON.stringify({
+ messages: [{ id: 'msg-a', subject: 'From A', date: '2026-01-01' }]
+ }));
+ });
+
+ const result = await getMessages({});
+ expect(result.total).toBe(2);
+ // Newest first
+ expect(result.messages[0].id).toBe('msg-b');
+ });
+
+ it('should return empty when readdir fails (no cache dir)', async () => {
+ readdir.mockRejectedValue(new Error('ENOENT'));
+
+ const result = await getMessages({});
+ expect(result.messages).toEqual([]);
+ expect(result.total).toBe(0);
+ });
+});
+
+// ─── getMessage ───
+
+describe('getMessage', () => {
+ it('should return a specific message by id', async () => {
+ const cache = {
+ messages: [
+ { id: 'msg-1', subject: 'Hello' },
+ { id: 'msg-2', subject: 'World' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(cache));
+
+ const result = await getMessage(VALID_UUID, 'msg-2');
+ expect(result.subject).toBe('World');
+ expect(result.accountId).toBe(VALID_UUID);
+ });
+
+ it('should return null when message not found', async () => {
+ readFile.mockResolvedValue(JSON.stringify({ messages: [] }));
+ const result = await getMessage(VALID_UUID, 'nonexistent');
+ expect(result).toBeNull();
+ });
+});
+
+// ─── deleteCache ───
+
+describe('deleteCache', () => {
+ it('should call unlink for valid accountId', async () => {
+ unlink.mockResolvedValue();
+ await deleteCache(VALID_UUID);
+ expect(unlink).toHaveBeenCalledWith(expect.stringContaining(`${VALID_UUID}.json`));
+ });
+
+ it('should silently skip invalid accountId', async () => {
+ await deleteCache('not-a-uuid');
+ expect(unlink).not.toHaveBeenCalled();
+ });
+});
+
+// ─── syncAccount ───
+
+describe('syncAccount', () => {
+ const mockIo = { emit: vi.fn() };
+
+ beforeEach(() => {
+ mockIo.emit.mockClear();
+ });
+
+ it('should return 400 when account is disabled', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Test', type: 'gmail', enabled: false });
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(result).toEqual({ error: 'Account is disabled', status: 400 });
+ expect(mockIo.emit).not.toHaveBeenCalled();
+ });
+
+ it('should return error when account not found', async () => {
+ getAccount.mockResolvedValue(null);
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(result).toEqual({ error: 'Account not found' });
+ });
+
+ it('should call syncGmail for gmail accounts and save cache', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Gmail', type: 'gmail', enabled: true });
+ readFile.mockResolvedValue(JSON.stringify({ syncCursor: null, messages: [] }));
+ syncGmail.mockResolvedValue([{ id: 'msg-1', externalId: 'ext-1', date: '2026-01-01' }]);
+ updateSyncStatus.mockResolvedValue();
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(syncGmail).toHaveBeenCalled();
+ expect(writeFile).toHaveBeenCalled();
+ expect(result.newMessages).toBe(1);
+ expect(result.total).toBe(1);
+ expect(result.status).toBe('success');
+ expect(updateSyncStatus).toHaveBeenCalledWith(VALID_UUID, 'success');
+ expect(mockIo.emit).toHaveBeenCalledWith('messages:sync:started', { accountId: VALID_UUID });
+ expect(mockIo.emit).toHaveBeenCalledWith('messages:sync:completed', expect.objectContaining({ accountId: VALID_UUID, newMessages: 1 }));
+ expect(mockIo.emit).toHaveBeenCalledWith('messages:changed', {});
+ });
+
+ it('should call syncPlaywright for outlook accounts', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Outlook', type: 'outlook', enabled: true });
+ readFile.mockResolvedValue(JSON.stringify({ syncCursor: null, messages: [] }));
+ syncPlaywright.mockResolvedValue([]);
+ updateSyncStatus.mockResolvedValue();
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(syncPlaywright).toHaveBeenCalled();
+ expect(result.newMessages).toBe(0);
+ });
+
+ it('should call syncPlaywright for teams accounts', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Teams', type: 'teams', enabled: true });
+ readFile.mockResolvedValue(JSON.stringify({ syncCursor: null, messages: [] }));
+ syncPlaywright.mockResolvedValue([]);
+ updateSyncStatus.mockResolvedValue();
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(syncPlaywright).toHaveBeenCalled();
+ });
+
+ it('should deduplicate by externalId during sync', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Gmail', type: 'gmail', enabled: true });
+ const existingCache = {
+ syncCursor: 'cur-1',
+ messages: [{ id: 'msg-1', externalId: 'ext-1', date: '2026-01-01' }]
+ };
+ readFile.mockResolvedValue(JSON.stringify(existingCache));
+ // Provider returns one duplicate and one new
+ syncGmail.mockResolvedValue([
+ { id: 'msg-1-dup', externalId: 'ext-1', date: '2026-01-01' },
+ { id: 'msg-2', externalId: 'ext-2', date: '2026-01-02' }
+ ]);
+ updateSyncStatus.mockResolvedValue();
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(result.newMessages).toBe(1);
+ expect(result.total).toBe(2); // 1 existing + 1 new
+ // Verify saved cache has 2 messages
+ const savedData = JSON.parse(writeFile.mock.calls[0][1]);
+ expect(savedData.messages).toHaveLength(2);
+ });
+
+ it('should keep messages without externalId (no dedup for those)', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Gmail', type: 'gmail', enabled: true });
+ readFile.mockResolvedValue(JSON.stringify({ messages: [{ id: 'msg-1' }] }));
+ syncGmail.mockResolvedValue([{ id: 'msg-2' }]); // no externalId
+ updateSyncStatus.mockResolvedValue();
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(result.newMessages).toBe(1);
+ expect(result.total).toBe(2);
+ });
+
+ it('should trim messages when exceeding maxMessages', async () => {
+ getAccount.mockResolvedValue({
+ id: VALID_UUID, name: 'Gmail', type: 'gmail', enabled: true,
+ syncConfig: { maxMessages: 2 }
+ });
+ const existingCache = {
+ messages: [
+ { id: 'msg-old', externalId: 'ext-old', date: '2026-01-01' },
+ { id: 'msg-mid', externalId: 'ext-mid', date: '2026-01-02' }
+ ]
+ };
+ readFile.mockResolvedValue(JSON.stringify(existingCache));
+ syncGmail.mockResolvedValue([
+ { id: 'msg-new', externalId: 'ext-new', date: '2026-01-03' }
+ ]);
+ updateSyncStatus.mockResolvedValue();
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(result.total).toBe(2); // trimmed from 3 to 2
+ const savedData = JSON.parse(writeFile.mock.calls[0][1]);
+ expect(savedData.messages).toHaveLength(2);
+ // Oldest message should have been trimmed
+ const ids = savedData.messages.map(m => m.id);
+ expect(ids).toContain('msg-new');
+ expect(ids).toContain('msg-mid');
+ expect(ids).not.toContain('msg-old');
+ });
+
+ it('should handle structured provider result with status', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Gmail', type: 'gmail', enabled: true });
+ readFile.mockResolvedValue(JSON.stringify({ messages: [] }));
+ syncGmail.mockResolvedValue({ messages: [{ id: 'msg-1', externalId: 'ext-1' }], status: 'partial' });
+ updateSyncStatus.mockResolvedValue();
+
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(result.status).toBe('partial');
+ expect(result.newMessages).toBe(1);
+ expect(updateSyncStatus).toHaveBeenCalledWith(VALID_UUID, 'partial');
+ // Should NOT emit messages:changed for non-success status
+ expect(mockIo.emit).not.toHaveBeenCalledWith('messages:changed', {});
+ expect(mockIo.emit).toHaveBeenCalledWith('messages:sync:completed', expect.objectContaining({ status: 'partial' }));
+ });
+
+ it('should return 409 when sync is already in progress (lock)', async () => {
+ // Use a deferred promise to keep loadCache hanging so the lock stays held
+ let resolveReadFile;
+ getAccount.mockResolvedValue({ id: VALID_UUID_2, name: 'Gmail', type: 'gmail', enabled: true });
+ // First readFile call (loadCache inside providerSync) hangs; subsequent calls resolve
+ let callCount = 0;
+ readFile.mockImplementation(() => {
+ callCount++;
+ if (callCount === 1) {
+ return new Promise(resolve => { resolveReadFile = resolve; });
+ }
+ return Promise.resolve(JSON.stringify({ messages: [] }));
+ });
+ updateSyncStatus.mockResolvedValue();
+
+ // Start first sync (don't await — it will hang on loadCache)
+ const firstSync = syncAccount(VALID_UUID_2, mockIo);
+ // Yield to let the first sync reach the lock point
+ await new Promise(r => setImmediate(r));
+
+ // Second sync should be rejected with 409
+ const secondResult = await syncAccount(VALID_UUID_2, mockIo);
+ expect(secondResult).toEqual({ error: 'Sync already in progress', status: 409 });
+
+ // Clean up: resolve the hanging readFile so firstSync completes
+ resolveReadFile(JSON.stringify({ messages: [] }));
+ await firstSync;
+ });
+
+ it('should release lock after sync completes', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Gmail', type: 'gmail', enabled: true });
+ readFile.mockResolvedValue(JSON.stringify({ messages: [] }));
+ syncGmail.mockResolvedValue([]);
+ updateSyncStatus.mockResolvedValue();
+
+ await syncAccount(VALID_UUID, mockIo);
+ // Second sync should work (lock released)
+ const result = await syncAccount(VALID_UUID, mockIo);
+ expect(result).not.toHaveProperty('status', 409);
+ });
+
+ it('should release lock and emit failed on provider error', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Err', type: 'badtype', enabled: true });
+ readFile.mockResolvedValue(JSON.stringify({ messages: [] }));
+ updateSyncStatus.mockResolvedValue();
+
+ // 'badtype' triggers throw new Error('Unsupported account type: badtype')
+ const result = await syncAccount(VALID_UUID, mockIo);
+
+ expect(result).toEqual({ error: 'Unsupported account type: badtype', status: 502 });
+ expect(updateSyncStatus).toHaveBeenCalledWith(VALID_UUID, 'error');
+ expect(mockIo.emit).toHaveBeenCalledWith('messages:sync:failed', {
+ accountId: VALID_UUID,
+ error: 'Unsupported account type: badtype'
+ });
+
+ // Lock should be released — next sync should not get 409
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Gmail', type: 'gmail', enabled: true });
+ const result2 = await syncAccount(VALID_UUID, mockIo);
+ expect(result2).not.toHaveProperty('status', 409);
+ });
+
+ it('should work when io is null/undefined', async () => {
+ getAccount.mockResolvedValue({ id: VALID_UUID, name: 'Gmail', type: 'gmail', enabled: true });
+ readFile.mockResolvedValue(JSON.stringify({ messages: [] }));
+ syncGmail.mockResolvedValue([]);
+ updateSyncStatus.mockResolvedValue();
+
+ const result = await syncAccount(VALID_UUID, null);
+ expect(result.newMessages).toBe(0);
+ });
+});
+
+// ─── getSyncStatus ───
+
+describe('getSyncStatus', () => {
+ it('should return sync status for existing account', async () => {
+ getAccount.mockResolvedValue({
+ id: VALID_UUID, lastSyncAt: '2026-01-01T00:00:00Z', lastSyncStatus: 'success'
+ });
+
+ const result = await getSyncStatus(VALID_UUID);
+
+ expect(result).toEqual({
+ accountId: VALID_UUID,
+ lastSyncAt: '2026-01-01T00:00:00Z',
+ lastSyncStatus: 'success'
+ });
+ });
+
+ it('should return null for nonexistent account', async () => {
+ getAccount.mockResolvedValue(null);
+ expect(await getSyncStatus(VALID_UUID)).toBeNull();
+ });
+});
diff --git a/server/services/pm2Standardizer.js b/server/services/pm2Standardizer.js
index 377f5b2f..62703ce7 100644
--- a/server/services/pm2Standardizer.js
+++ b/server/services/pm2Standardizer.js
@@ -121,6 +121,7 @@ ${context.viteConfigs.map(f => `#### ${f.name}\n\`\`\`javascript\n${f.content}\n
5. **Vite processes**: Use \`npx vite --host --port XXXX\` in args, disable watch (Vite has HMR)
6. **Set cwd** for each process pointing to its directory
7. **Include NODE_ENV** in all env blocks
+8. **UI vs Dev UI ports**: When the API server serves the production build of the frontend (Express static files), the production UI port equals the API port. The Vite dev server port is a separate "dev UI" port only used during development. Label Vite dev ports as UI or DEV_UI in the PORTS object (both conventions are acceptable).
## Output Format
@@ -497,7 +498,14 @@ export function getStandardTemplate() {
return `// PM2 Ecosystem Configuration Template
// All ports should be defined here, not in .env or vite.config
+// Port definitions as single source of truth
+const PORTS = {
+ API: 3001, // Express API server (also serves prod UI build)
+ UI: 3000 // Vite dev server (development only)
+};
+
module.exports = {
+ PORTS,
apps: [
{
name: 'myapp-server',
@@ -508,18 +516,19 @@ module.exports = {
ignore_watch: ['node_modules', 'data', '*.log'],
env: {
NODE_ENV: 'development',
- PORT: 3001
+ PORT: PORTS.API
}
},
{
name: 'myapp-client',
cwd: './client',
script: 'npx',
- args: 'vite --host --port 3000',
+ args: 'vite --host --port ' + PORTS.UI,
watch: false,
env: {
NODE_ENV: 'development',
- PORT: 3000
+ // VITE_PORT is a PortOS convention for port discovery (read by streamingDetect/detect), not consumed by Vite itself
+ VITE_PORT: PORTS.UI
}
}
]
diff --git a/server/services/streamingDetect.js b/server/services/streamingDetect.js
index 394839a2..dd48d129 100644
--- a/server/services/streamingDetect.js
+++ b/server/services/streamingDetect.js
@@ -273,12 +273,28 @@ export function parseEcosystemConfig(content) {
}
// Check if this process uses vite (need to check vite.config in cwd)
- const usesVite = /\bvite\b/i.test(appBlock);
+ // Match explicit "vite" command OR VITE_PORT in env config
+ const usesVite = /\bvite\b/i.test(appBlock) || /VITE_PORT/i.test(appBlock);
processes.push({ name: processName, port, ports, cwd, usesVite });
lastIndex = endPos;
}
+ // Post-process: when an app has both an API process and Vite dev processes,
+ // relabel Vite ports from 'ui' to 'devUi' since the prod UI is served by the API.
+ const hasApiProcess = processes.some(p => p.ports?.api);
+ if (hasApiProcess) {
+ for (const proc of processes) {
+ if (proc.usesVite && proc.ports?.ui && !proc.ports?.devUi) {
+ proc.ports.devUi = proc.ports.ui;
+ delete proc.ports.ui;
+ // Update primary port reference
+ const portValues = Object.values(proc.ports);
+ proc.port = portValues.length > 0 ? portValues[0] : null;
+ }
+ }
+ }
+
return { processes, pm2Home };
}
@@ -341,6 +357,7 @@ export async function streamDetection(socket, dirPath) {
name: '',
description: '',
uiPort: null,
+ devUiPort: null,
apiPort: null,
startCommands: [],
pm2ProcessNames: [],
@@ -425,7 +442,7 @@ export async function streamDetection(socket, dirPath) {
const portMatch = content.match(/PORT\s*=\s*(\d+)/i);
if (portMatch) result.apiPort = parseInt(portMatch[1], 10);
const viteMatch = content.match(/VITE_PORT\s*=\s*(\d+)/i);
- if (viteMatch) result.uiPort = parseInt(viteMatch[1], 10);
+ if (viteMatch) result.devUiPort = parseInt(viteMatch[1], 10);
configFiles.push('.env');
}
@@ -435,7 +452,7 @@ export async function streamDetection(socket, dirPath) {
if (existsSync(configPath)) {
const content = await readFile(configPath, 'utf-8').catch(() => '');
const portMatch = content.match(/port\s*:\s*(\d+)/);
- if (portMatch) result.uiPort = parseInt(portMatch[1], 10);
+ if (portMatch) result.devUiPort = parseInt(portMatch[1], 10);
configFiles.push(viteConfig);
}
}
@@ -471,15 +488,28 @@ export async function streamDetection(socket, dirPath) {
// Clean up internal properties
delete proc.cwd;
delete proc.usesVite;
- }
+ }
result.processes = parsedProcesses;
result.pm2ProcessNames = parsedProcesses.map(p => p.name);
- // Set apiPort from first process with a port (usually the main server)
- const processWithPort = parsedProcesses.find(p => p.port);
- if (processWithPort && !result.apiPort) {
- result.apiPort = processWithPort.port;
+ // Derive ports from parsed processes
+ const apiProc = parsedProcesses.find(p => p.ports?.api);
+ if (apiProc && !result.apiPort) {
+ result.apiPort = apiProc.ports.api;
+ }
+ const uiProc = parsedProcesses.find(p => p.ports?.ui);
+ if (uiProc && !result.uiPort) {
+ result.uiPort = uiProc.ports.ui;
+ }
+ const devUiProc = parsedProcesses.find(p => p.ports?.devUi);
+ if (devUiProc && !result.devUiPort) {
+ result.devUiPort = devUiProc.ports.devUi;
+ }
+ // When app has API + Vite dev but no dedicated UI port,
+ // the prod UI is served by the API server
+ if (!result.uiPort && result.apiPort && result.devUiPort) {
+ result.uiPort = result.apiPort;
}
}
@@ -494,9 +524,16 @@ export async function streamDetection(socket, dirPath) {
}
}
+ // When config-file heuristics found a Vite dev port but no dedicated uiPort,
+ // derive uiPort: API serves prod UI if present, otherwise devUiPort is the only UI
+ if (!result.uiPort && result.devUiPort) {
+ result.uiPort = result.apiPort ?? result.devUiPort;
+ }
+
emit('config', 'done', {
message: configFiles.length ? `Found: ${configFiles.join(', ')}` : 'No config files found',
uiPort: result.uiPort,
+ devUiPort: result.devUiPort,
apiPort: result.apiPort,
pm2ProcessNames: result.pm2ProcessNames.length > 0 ? result.pm2ProcessNames : undefined,
processes: result.processes.length > 0 ? result.processes : undefined,