-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add unified email/messaging management system #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
7b9e819
feat: add unified email/messaging management system
atomantic 1fcd989
address review: fix error handling, naming, sync resilience, and inpu…
atomantic b6adc4f
address review: fix path traversal, delete handler, and sender fallback
atomantic 985c128
address review: fix route ordering, schema validation, and prototype …
atomantic d7eb66b
address review: debounce search, validate draft account, cleanup on d…
atomantic f679a9f
feat: implement CDP browser sync for Outlook/Teams messages
atomantic baf6b23
address review: schema consistency, UUID validation, sync:failed list…
atomantic 2c2280f
address review: cache resilience, selector injection, WebSocket leak,…
atomantic eb49710
address review: per-account sync locking and structured provider status
atomantic 8349d60
address review: sendVia validation, disabled account guard, structure…
atomantic 9aee426
address review: structured sync returns, draft dedup, test contract a…
atomantic bd89dc4
address review: CDP timer cleanup, WS parse safety, consistent struct…
atomantic 8a7c408
address review: move message detail route to end to avoid capturing l…
atomantic c9b9ef6
address review: add type guards after safeJSONParse for all message d…
atomantic 2ba7f39
address review: structured stub returns, send result checking, CDP de…
atomantic 10927e0
address review: empty selector guard, NaN-safe date sort, update sche…
atomantic 34bee00
address review: include accountId in getMessage, emit failed for non-…
atomantic 4fed50c
fix(apps): label Vite dev ports as devUi when API process coexists
atomantic 64a602c
address review: drafts UUID validation, fix duplicate sync events, do…
atomantic 425b83d
address review: always emit sync:completed, reactive account updates
atomantic 4d9a473
fix(messages): always render selector cards for supported providers
atomantic 6b6bad9
address review: structured sync errors, remove duplicate toasts, add …
atomantic 7151fe7
address review: docs consistency, dead prop, deleteCache logging, tes…
atomantic 7247003
address review: Vite detection by VITE_PORT, PORTS.UI naming, conditi…
atomantic 00a5439
fix: VITE_PORT references PORTS.UI consistently in pm2 template
atomantic 094f77d
address review: align port guidance, fix devUiPort heuristics, remove…
atomantic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| import { useState } from 'react'; | ||
| import { Plus, Trash2, RefreshCw, Mail, Globe, MessageSquare } from 'lucide-react'; | ||
| import toast from 'react-hot-toast'; | ||
| import * as api from '../../services/api'; | ||
|
|
||
| const TYPE_ICONS = { gmail: Mail, outlook: Globe, teams: MessageSquare }; | ||
| const TYPE_LABELS = { gmail: 'Gmail (MCP)', outlook: 'Outlook (Playwright)', teams: 'Teams (Playwright)' }; | ||
|
|
||
| export default function AccountsTab({ accounts, setAccounts }) { | ||
| const [showForm, setShowForm] = useState(false); | ||
| const [form, setForm] = useState({ name: '', type: 'gmail', email: '' }); | ||
| const [saving, setSaving] = useState(false); | ||
| const [deleting, setDeleting] = useState(null); | ||
|
|
||
| const handleCreate = async () => { | ||
| if (!form.name) return toast.error('Name is required'); | ||
| setSaving(true); | ||
| const result = await api.createMessageAccount(form).catch(() => null); | ||
| setSaving(false); | ||
| if (!result) return toast.error('Failed to create account'); | ||
| setShowForm(false); | ||
| setForm({ name: '', type: 'gmail', email: '' }); | ||
| toast.success('Account created'); | ||
| setAccounts(prev => [...prev, result]); | ||
| }; | ||
|
|
||
| const handleDelete = async (id) => { | ||
| setDeleting(id); | ||
| const ok = await api.deleteMessageAccount(id).then(() => true).catch(() => false); | ||
| setDeleting(null); | ||
| if (!ok) return; | ||
| toast.success('Account deleted'); | ||
| setAccounts(prev => prev.filter(a => a.id !== id)); | ||
| }; | ||
|
|
||
| const handleToggle = async (account) => { | ||
| const result = await api.updateMessageAccount(account.id, { enabled: !account.enabled }).catch(() => null); | ||
| if (!result) return toast.error('Failed to update account'); | ||
| toast.success(account.enabled ? 'Account disabled' : 'Account enabled'); | ||
| setAccounts(prev => prev.map(a => a.id === account.id ? { ...a, enabled: !a.enabled } : a)); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-4"> | ||
| <div className="flex items-center justify-between"> | ||
| <h2 className="text-lg font-semibold text-white">Accounts</h2> | ||
| <button | ||
| onClick={() => setShowForm(!showForm)} | ||
| className="flex items-center gap-2 px-3 py-2 bg-port-accent text-white rounded-lg text-sm hover:bg-port-accent/80 transition-colors" | ||
| > | ||
| <Plus size={16} /> | ||
| Add Account | ||
| </button> | ||
| </div> | ||
|
|
||
| {showForm && ( | ||
| <div className="p-4 bg-port-card rounded-lg border border-port-border space-y-3"> | ||
| <div> | ||
| <label className="block text-sm text-gray-400 mb-1">Name</label> | ||
| <input | ||
| type="text" | ||
| value={form.name} | ||
| onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} | ||
| placeholder="e.g. Work Gmail" | ||
| className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-port-accent" | ||
| /> | ||
| </div> | ||
| <div> | ||
| <label className="block text-sm text-gray-400 mb-1">Type</label> | ||
| <select | ||
| value={form.type} | ||
| onChange={(e) => setForm(f => ({ ...f, type: e.target.value }))} | ||
| className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-sm text-white focus:outline-none focus:border-port-accent" | ||
| > | ||
| <option value="gmail">Gmail (MCP)</option> | ||
| <option value="outlook">Outlook (Playwright)</option> | ||
| <option value="teams">Teams (Playwright)</option> | ||
| </select> | ||
| </div> | ||
| <div> | ||
| <label className="block text-sm text-gray-400 mb-1">Email</label> | ||
| <input | ||
| type="email" | ||
| value={form.email} | ||
| onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} | ||
| placeholder="user@example.com" | ||
| className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-port-accent" | ||
| /> | ||
| </div> | ||
| <div className="flex gap-2"> | ||
| <button | ||
| onClick={handleCreate} | ||
| disabled={saving} | ||
| className="px-4 py-2 bg-port-accent text-white rounded-lg text-sm hover:bg-port-accent/80 transition-colors disabled:opacity-50" | ||
| > | ||
| {saving ? 'Creating...' : 'Create'} | ||
| </button> | ||
| <button | ||
| onClick={() => setShowForm(false)} | ||
| className="px-4 py-2 bg-port-border text-gray-300 rounded-lg text-sm hover:bg-port-border/80 transition-colors" | ||
| > | ||
| Cancel | ||
| </button> | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| {accounts.length === 0 && !showForm && ( | ||
| <div className="text-center py-12 text-gray-500"> | ||
| <Mail size={48} className="mx-auto mb-4 opacity-50" /> | ||
| <p>No accounts configured</p> | ||
| <p className="text-sm mt-1">Add a Gmail, Outlook, or Teams account to get started</p> | ||
| </div> | ||
| )} | ||
|
|
||
| <div className="space-y-2"> | ||
| {accounts.map((account) => { | ||
| const Icon = TYPE_ICONS[account.type] || Mail; | ||
| return ( | ||
| <div | ||
| key={account.id} | ||
| className="flex items-center justify-between p-4 bg-port-card rounded-lg border border-port-border" | ||
| > | ||
| <div className="flex items-center gap-3"> | ||
| <Icon size={20} className={account.enabled ? 'text-port-accent' : 'text-gray-600'} /> | ||
| <div> | ||
| <div className="text-sm font-medium text-white">{account.name}</div> | ||
| <div className="text-xs text-gray-500"> | ||
| {TYPE_LABELS[account.type]} · {account.email || 'No email set'} | ||
| </div> | ||
| {account.lastSyncAt && ( | ||
| <div className="text-xs text-gray-600"> | ||
| Last sync: {new Date(account.lastSyncAt).toLocaleString()} ({account.lastSyncStatus}) | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| <button | ||
| onClick={() => handleToggle(account)} | ||
| className={`px-2 py-1 rounded text-xs transition-colors ${ | ||
| account.enabled | ||
| ? 'bg-port-success/20 text-port-success' | ||
| : 'bg-gray-700 text-gray-400' | ||
| }`} | ||
| > | ||
| {account.enabled ? 'Enabled' : 'Disabled'} | ||
| </button> | ||
| <button | ||
| onClick={() => handleDelete(account.id)} | ||
| disabled={deleting === account.id} | ||
| className="p-1 text-gray-500 hover:text-port-error transition-colors" | ||
| title="Delete account" | ||
| > | ||
| {deleting === account.id ? ( | ||
| <RefreshCw size={16} className="animate-spin" /> | ||
| ) : ( | ||
| <Trash2 size={16} /> | ||
| )} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import { useState, useEffect, useCallback } from 'react'; | ||
| import { FileText, Trash2, Send, Check, RefreshCw } from 'lucide-react'; | ||
| import toast from 'react-hot-toast'; | ||
| import * as api from '../../services/api'; | ||
|
|
||
| export default function DraftsTab({ accounts }) { | ||
| const [drafts, setDrafts] = useState([]); | ||
| const [loading, setLoading] = useState(true); | ||
|
|
||
| const fetchDrafts = useCallback(async () => { | ||
| setLoading(true); | ||
| const data = await api.getMessageDrafts().catch(() => []); | ||
| setDrafts(data || []); | ||
| setLoading(false); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| fetchDrafts(); | ||
| }, [fetchDrafts]); | ||
|
|
||
| const handleApprove = async (id) => { | ||
| const result = await api.approveMessageDraft(id).catch(() => null); | ||
| if (!result) return; | ||
| setDrafts(prev => prev.map(d => d.id === id ? { ...d, status: 'approved' } : d)); | ||
| toast.success('Draft approved'); | ||
| }; | ||
|
|
||
| const handleSend = async (id) => { | ||
| const result = await api.sendMessageDraft(id).catch(() => null); | ||
| if (!result || result.success === false) return; | ||
| setDrafts(prev => prev.map(d => d.id === id ? { ...d, status: 'sent' } : d)); | ||
| toast.success('Message sent'); | ||
| }; | ||
|
|
||
| const handleDelete = async (id) => { | ||
| const ok = await api.deleteMessageDraft(id).then(() => true).catch(() => false); | ||
| if (!ok) return; | ||
| setDrafts(prev => prev.filter(d => d.id !== id)); | ||
| toast.success('Draft deleted'); | ||
| }; | ||
|
|
||
| const getAccountName = (accountId) => { | ||
| const account = accounts.find(a => a.id === accountId); | ||
| return account?.name || 'Unknown'; | ||
| }; | ||
|
|
||
| const statusColors = { | ||
| draft: 'bg-gray-700 text-gray-300', | ||
| pending_review: 'bg-port-warning/20 text-port-warning', | ||
| approved: 'bg-port-success/20 text-port-success', | ||
| sending: 'bg-port-accent/20 text-port-accent', | ||
| sent: 'bg-port-success/20 text-port-success', | ||
| failed: 'bg-port-error/20 text-port-error' | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-4"> | ||
| <div className="flex items-center justify-between"> | ||
| <h2 className="text-lg font-semibold text-white">Drafts</h2> | ||
| <button | ||
| onClick={fetchDrafts} | ||
| className="p-2 text-gray-400 hover:text-white transition-colors" | ||
| > | ||
| <RefreshCw size={16} className={loading ? 'animate-spin' : ''} /> | ||
| </button> | ||
| </div> | ||
|
|
||
| {drafts.length === 0 && !loading && ( | ||
| <div className="text-center py-12 text-gray-500"> | ||
| <FileText size={48} className="mx-auto mb-4 opacity-50" /> | ||
| <p>No drafts</p> | ||
| <p className="text-sm mt-1">Generate AI replies from the Inbox or create manual drafts</p> | ||
| </div> | ||
| )} | ||
|
|
||
| <div className="space-y-2"> | ||
| {drafts.map((draft) => ( | ||
| <div | ||
| key={draft.id} | ||
| className="p-4 bg-port-card rounded-lg border border-port-border space-y-2" | ||
| > | ||
| <div className="flex items-center justify-between"> | ||
| <div className="flex items-center gap-2"> | ||
| <span className={`px-2 py-0.5 rounded text-xs ${statusColors[draft.status] || ''}`}> | ||
| {draft.status} | ||
| </span> | ||
| <span className="text-xs text-gray-500">{getAccountName(draft.accountId)}</span> | ||
| {draft.generatedBy === 'ai' && ( | ||
| <span className="text-xs text-purple-400">AI generated</span> | ||
| )} | ||
| </div> | ||
| <div className="flex items-center gap-1"> | ||
| {draft.status === 'draft' && ( | ||
| <button | ||
| onClick={() => handleApprove(draft.id)} | ||
| className="p-1 text-gray-400 hover:text-port-success transition-colors" | ||
| title="Approve" | ||
| > | ||
| <Check size={16} /> | ||
| </button> | ||
| )} | ||
| {draft.status === 'approved' && ( | ||
| <button | ||
| onClick={() => handleSend(draft.id)} | ||
| className="p-1 text-gray-400 hover:text-port-accent transition-colors" | ||
| title="Send" | ||
| > | ||
| <Send size={16} /> | ||
| </button> | ||
| )} | ||
| {['draft', 'pending_review', 'failed'].includes(draft.status) && ( | ||
| <button | ||
| onClick={() => handleDelete(draft.id)} | ||
| className="p-1 text-gray-400 hover:text-port-error transition-colors" | ||
| title="Delete" | ||
| > | ||
| <Trash2 size={16} /> | ||
| </button> | ||
| )} | ||
| </div> | ||
| </div> | ||
| <div className="text-sm text-white">{draft.subject || '(no subject)'}</div> | ||
| {draft.to?.length > 0 && ( | ||
| <div className="text-xs text-gray-500">To: {draft.to.join(', ')}</div> | ||
| )} | ||
| <div className="text-sm text-gray-400 whitespace-pre-wrap line-clamp-3"> | ||
| {draft.body} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.