email leaves the platform for plugins/email

the sidecar, the router, the table, the screens and the account manager are
gone from src/ entirely - deleted, not commented, because a commented mount is
what assertPermissionTotality cannot see.

three reverse imports had to go first, and they are what decided the boundary.
core may not import a plugin's queries, and one of these had already proved
why: getEmailAccounts was awaited at the top level of the agent sidecar, so
when the table went away on 2026-08-13 the rejection took officer-agent into a
pm2 restart loop with every live chat session inside it (fbb6d6c7).

both survivors now read the DISK instead. findEmailAccountName() in
data-path.ts picks the newest emails.db under an account's email_accounts/
directory - no import, no query, and nothing to throw when the plugin is not
installed. the layout was already the platform's, so this costs no coupling.
the agent keeps its email_db tool and an email-context chat still opens in the
right mailbox.

the google oauth callback no longer appends /email to the dock: a plugin's
route is the plugin's business.

app-store/catalogue.ts entry deleted, and that is the whole point rather than
tidying. permissionAvailability derives from sidecar_installs and a plugin only
ever gets a plugin_installs row, so a leftover entry makes the permission
permanently unavailable - denied route, no dock tile - on a machine where the
plugin is installed and healthy. it has bitten headscale and nearly music.

two app-store tests used email as their config-only fixture; repointed at
notify, which is the same shape.

google-auth stays in core and the plugin imports it via @@/. that direction is
allowed and google-auth knows nothing about mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 00:28:13 +00:00
co-authored by Claude Opus 5
parent 4655d374ec
commit 98c37c4ce9
44 changed files with 58 additions and 5583 deletions
-2
View File
@@ -135,8 +135,6 @@ export function App() {
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
<Route path="/email" element={<Dashboard.EmailScreen />} />
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
<Route path="/browser/:tabId" element={<Dashboard.BrowserScreen />} />
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
@@ -1,423 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { X, Loader2, Send, Paperclip } from 'lucide-react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
export type ComposeDraft = { to?: string; cc?: string; subject?: string; body?: string; inReplyTo?: string };
// Shared open/close handle. Set a draft to open the composer; null closes it.
export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE', null);
type Contact = { address: string; name: string };
// A recipient field with contact autocomplete on the last comma-separated segment.
const RecipientInput = ({
value,
onChange,
placeholder,
autoFocus,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
autoFocus?: boolean;
}) => {
const client = useClient();
const [suggestions, setSuggestions] = useState<Contact[]>([]);
const [open, setOpen] = useState(false);
const seg = value.split(',').pop()?.trim() ?? '';
useEffect(() => {
if (seg.length < 1) {
setSuggestions([]);
return;
}
const t = setTimeout(() => {
client
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
.then(setSuggestions)
.catch(() => {});
}, 180);
return () => clearTimeout(t);
}, [seg]);
const pick = (address: string) => {
const segs = value.split(',');
segs[segs.length - 1] = ` ${address}`;
onChange(segs.join(',').replace(/^\s+/, '') + ', ');
setOpen(false);
};
return (
<div className="relative">
<input
autoFocus={autoFocus}
value={value}
onChange={(ev) => {
onChange(ev.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={placeholder}
className="w-full bg-transparent px-3 py-2 text-sm outline-none placeholder:opacity-40"
/>
{open && suggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full z-10 mt-0.5 max-h-56 overflow-y-auto rounded-md border bg-background shadow-lg">
{suggestions.map((s) => (
<button
key={s.address}
onMouseDown={(ev) => {
ev.preventDefault();
pick(s.address);
}}
className="flex w-full flex-col items-start px-3 py-1.5 text-left text-sm hover:bg-accent cursor-pointer"
>
{s.name && <span className="font-medium">{s.name}</span>}
<span className={s.name ? 'text-xs opacity-60' : ''}>{s.address}</span>
</button>
))}
</div>
)}
</div>
);
};
const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
export const ComposeModal = () => {
const client = useClient();
const queryClient = useQueryClient();
const [draft, setDraft] = useComposer();
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [showCc, setShowCc] = useState(false);
const [bcc, setBcc] = useState('');
const [showBcc, setShowBcc] = useState(false);
const [subject, setSubject] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [dragging, setDragging] = useState(false);
const [bodyEmpty, setBodyEmpty] = useState(true);
const [sending, setSending] = useState(false);
const seeded = useRef<ComposeDraft | null>(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const editorRef = useRef<HTMLDivElement | null>(null);
const inlineMap = useRef(new Map<string, File>()); // data-inline-id → File, for the images embedded in the body
const nextImgId = useRef(0);
const refreshEmpty = () => {
const el = editorRef.current;
setBodyEmpty(!el || (!el.textContent?.trim() && !el.querySelector('img')));
};
// Free object URLs for any images still in the editor, and clear the id→File map.
const revokeInline = () => {
editorRef.current?.querySelectorAll('img[data-inline-id]').forEach((img) => {
const src = (img as HTMLImageElement).src;
if (src.startsWith('blob:')) URL.revokeObjectURL(src);
});
inlineMap.current.clear();
};
useEffect(() => {
if (draft && draft !== seeded.current) {
seeded.current = draft;
setTo(draft.to ?? '');
setCc(draft.cc ?? '');
setShowCc(!!draft.cc);
setBcc('');
setShowBcc(false);
setSubject(draft.subject ?? '');
setFiles([]);
inlineMap.current.clear();
nextImgId.current = 0;
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
if (editorRef.current)
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
refreshEmpty();
}
if (!draft) seeded.current = null;
}, [draft]);
// Clipboard images often come nameless — give them a sensible filename.
const named = (f: File) =>
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
const addAttachments = (incoming: FileList | File[]) => {
const list = Array.from(incoming).map(named);
if (list.length) setFiles((prev) => [...prev, ...list]);
};
// Insert an image into the body at the given range (or the current caret, or the end).
const insertImage = (file: File, at?: Range | null) => {
const el = editorRef.current;
if (!el) return;
const id = `img-${nextImgId.current++}`;
inlineMap.current.set(id, file);
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.setAttribute('data-inline-id', id);
img.style.maxWidth = '100%';
img.style.height = 'auto';
const sel = window.getSelection();
const caret = at ?? (sel && sel.rangeCount && el.contains(sel.anchorNode) ? sel.getRangeAt(0) : null);
if (caret && el.contains(caret.startContainer)) {
caret.deleteContents();
caret.insertNode(img);
caret.setStartAfter(img);
caret.collapse(true);
sel?.removeAllRanges();
sel?.addRange(caret);
} else {
el.appendChild(img);
}
refreshEmpty();
};
// Paste inside the body: images insert at the caret, other files attach, text pastes as plain text.
const onEditorPaste = (ev: React.ClipboardEvent) => {
const fileItems = Array.from(ev.clipboardData?.items ?? []).filter((it) => it.kind === 'file');
if (fileItems.length) {
ev.preventDefault();
fileItems
.map((it) => it.getAsFile())
.filter((f): f is File => !!f)
.map(named)
.forEach((f) => (f.type.startsWith('image/') ? insertImage(f) : addAttachments([f])));
return;
}
const text = ev.clipboardData?.getData('text/plain');
if (text) {
ev.preventDefault();
document.execCommand('insertText', false, text);
}
};
const onDrop = (ev: React.DragEvent) => {
ev.preventDefault();
setDragging(false);
const dropped = Array.from(ev.dataTransfer?.files ?? []).map(named);
if (!dropped.length) return;
// Place images at the drop point if it lands inside the body, otherwise append at the end.
const el = editorRef.current;
const point = document.caretRangeFromPoint?.(ev.clientX, ev.clientY) ?? null;
const range = point && el?.contains(point.startContainer) ? point : null;
dropped.forEach((f) => (f.type.startsWith('image/') ? insertImage(f, range) : addAttachments([f])));
};
const close = () => {
revokeInline();
setDraft(null);
};
const send = async () => {
if (!to.trim()) {
toast.error('Add at least one recipient');
return;
}
setSending(true);
try {
const fd = new FormData();
fd.append('to', to);
if (cc.trim()) fd.append('cc', cc);
if (bcc.trim()) fd.append('bcc', bcc);
fd.append('subject', subject);
if (draft?.inReplyTo) fd.append('inReplyTo', draft.inReplyTo);
files.forEach((f) => fd.append('files', f));
// Serialize the body. Clone it, rewrite each inline <img> to a `cid:inline-N` ref (backend
// matches by order), and collect those files. Plain text goes as the fallback part.
const el = editorRef.current;
const inlineFiles: File[] = [];
if (el) {
const clone = el.cloneNode(true) as HTMLElement;
clone.querySelectorAll('img[data-inline-id]').forEach((node) => {
const img = node as HTMLImageElement;
const file = inlineMap.current.get(img.getAttribute('data-inline-id') ?? '');
if (!file) return img.remove();
img.setAttribute('src', `cid:inline-${inlineFiles.length}`);
img.removeAttribute('data-inline-id');
inlineFiles.push(file);
});
fd.append('body', el.innerText);
if (inlineFiles.length) {
fd.append('html', clone.innerHTML);
inlineFiles.forEach((f) => fd.append('inline', f));
}
}
await client.post('/email/send', fd);
toast.success('Email sent');
// Gmail files the sent copy in Sent, so refresh the list to surface it.
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
close();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
} finally {
setSending(false);
}
};
const fmtSize = (n: number) =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
if (!draft) return null;
return (
<Dialog open onOpenChange={(o) => !o && close()}>
<DialogContent
className="flex h-[70vh] max-w-2xl flex-col gap-0 overflow-hidden p-0"
onDrop={onDrop}
onDragOver={(ev) => {
ev.preventDefault();
setDragging(true);
}}
onDragLeave={(ev) => {
if (ev.currentTarget === ev.target) setDragging(false);
}}
>
{dragging && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-md border-2 border-dashed border-duck-teal bg-background/80 text-sm font-medium">
Drop images to inline · other files attach
</div>
)}
<div className="flex items-center border-b px-4 py-2.5 pr-10">
<DialogTitle className="text-sm font-semibold">{/^re:/i.test(subject) ? 'Reply' : 'New message'}</DialogTitle>
</div>
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">To</span>
<div className="flex-1">
<RecipientInput value={to} onChange={setTo} placeholder="Recipients" autoFocus />
</div>
<div className="flex items-center gap-2 px-3">
{!showCc && (
<button onClick={() => setShowCc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
Cc
</button>
)}
{!showBcc && (
<button onClick={() => setShowBcc(true)} className="text-xs opacity-50 hover:opacity-100 cursor-pointer">
Bcc
</button>
)}
</div>
</div>
{showCc && (
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">Cc</span>
<div className="flex-1">
<RecipientInput value={cc} onChange={setCc} placeholder="Cc recipients" />
</div>
</div>
)}
{showBcc && (
<div className="flex items-center border-b">
<span className="pl-4 text-xs opacity-50">Bcc</span>
<div className="flex-1">
<RecipientInput value={bcc} onChange={setBcc} placeholder="Bcc recipients" />
</div>
</div>
)}
<input
value={subject}
onChange={(ev) => setSubject(ev.target.value)}
placeholder="Subject"
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
/>
<div className="relative flex-1 overflow-hidden">
{bodyEmpty && (
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message…</div>
)}
<div
ref={editorRef}
contentEditable
onInput={refreshEmpty}
onPaste={onEditorPaste}
className="h-full overflow-y-auto px-4 py-3 text-sm outline-none [&_img]:my-1 [&_img]:rounded"
/>
</div>
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t px-4 py-2">
{files.map((f, i) => (
<span key={`${f.name}-${i}`} className="flex items-center gap-1.5 rounded bg-accent px-2 py-1 text-xs">
<Paperclip className="h-3 w-3 opacity-60" />
<span className="max-w-[16rem] truncate">{f.name}</span>
<span className="opacity-50">{fmtSize(f.size)}</span>
<button
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
className="opacity-50 hover:opacity-100 cursor-pointer"
title="Remove"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-center justify-end gap-2 border-t px-4 py-2.5">
<input
ref={fileInput}
type="file"
multiple
className="hidden"
onChange={(ev) => {
if (ev.target.files?.length) addAttachments(ev.target.files);
ev.target.value = '';
}}
/>
<button
onClick={() => fileInput.current?.click()}
className="mr-auto flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
title="Attach files"
>
<Paperclip className="h-4 w-4" />
</button>
<button
onClick={close}
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
>
Cancel
</button>
<button
onClick={send}
disabled={sending}
className="flex items-center gap-1.5 rounded-md bg-duck-teal px-4 py-1.5 text-sm font-medium text-white hover:bg-duck-teal/90 disabled:opacity-50 cursor-pointer"
>
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Send
</button>
</div>
</DialogContent>
</Dialog>
);
};
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
// is a follow-up: store the Message-Id header on ingest.)
export const replyDraft = (m: {
from: string;
subject: string;
date: string;
text?: string;
snippet?: string;
}): ComposeDraft => {
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
const original = (m.text || m.snippet || '').trim();
const quoted = original
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
.split('\n')
.map((l) => `> ${l}`)
.join('\n')}`
: '';
return { to: addr, subject, body: quoted };
};
@@ -1,399 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import {
ChevronLeft,
ChevronRight,
Inbox,
Loader2,
Mail,
Paperclip,
RefreshCw,
Search,
Send,
ShieldAlert,
SquarePen,
Trash2,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import type { EmailSummary } from 'types';
import { useComposer } from './Compose';
import { emailPath, useSelectedEmailId } from './shared';
type EmailAccountRow = {
id: number;
provider: string;
email: string;
status: string;
};
const LIMIT = 50;
const FOLDERS = [
{ key: 'inbox', label: 'Inbox', icon: Inbox },
{ key: 'all', label: 'All', icon: Mail },
{ key: 'sent', label: 'Sent', icon: Send },
{ key: 'spam', label: 'Spam', icon: ShieldAlert },
{ key: 'trash', label: 'Trash', icon: Trash2 },
] as const;
const formatDate = (iso: string) => {
const date = new Date(iso);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const isThisYear = date.getFullYear() === now.getFullYear();
if (isThisYear) return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
};
export const EmailList = () => {
const client = useClient();
const queryClient = useQueryClient();
const [, openCompose] = useComposer();
const navigate = useNavigate();
const selectedId = useSelectedEmailId();
// Which folder you are reading is addressable state, so it is a query param rather than a global.
// It stays a *param* and not a path segment because the path already spells which email is open:
// `/email/:emailId` is the thing, the folder is the view you found it through, and switching folders
// with a message open should not close it.
const [searchParams, setSearchParams] = useSearchParams();
const folder = searchParams.get('folder') ?? 'inbox';
const qs = searchParams.toString() ? `?${searchParams}` : '';
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const isSearching = debouncedSearch.length > 0;
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(t);
}, [search]);
// Page 1 on a new search *or* a new folder. The folder half used to live in the click handler; it
// cannot any more, because the folder pills are links and nobody handles their click.
useEffect(() => {
setPage(1);
}, [debouncedSearch, folder]);
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
queryKey: ['email-accounts'],
queryFn: () => client.get<EmailAccountRow[]>('/email/accounts'),
});
const syncableAccount = emailAccounts.find((a) => a.status === 'connected' || a.status === 'synced');
const isSyncing = emailAccounts.some((a) => a.status === 'syncing' || a.status === 'queued');
const hasAccounts = emailAccounts.length > 0;
const { data, isLoading } = useQuery({
queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder],
queryFn: () =>
isSearching
? client.get<{ messages: EmailSummary[]; total: number }>(
`/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`,
)
: client.get<{ messages: EmailSummary[]; total: number }>(
`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`,
),
// Keep the previous results visible while the next query loads, so switching search terms/pages
// never bails to the full-panel loading view (which would unmount the search input and drop focus).
placeholderData: keepPreviousData,
});
// Auto-switch to "all" if inbox filter returns nothing but emails exist (not while searching)
const { data: allCount } = useQuery({
queryKey: ['email-messages-all-count'],
queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'),
enabled: !isSearching && folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0,
});
useEffect(() => {
if (!isSearching && folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) {
// `replace`: this is the app correcting its own default, not a place you chose to be, so Back
// should leave /email rather than bounce you between inbox and all.
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set('folder', 'all');
return next;
},
{ replace: true },
);
}
}, [isSearching, folder, data?.total, allCount, setSearchParams]);
// Live updates: while /email is open, listen for new-mail pushes (IMAP IDLE → SSE) and refetch.
// The EventSource closes automatically when this component unmounts (i.e. when you leave /email).
useEffect(() => {
const token = localStorage.getItem('BEARER_TOKEN');
if (!token) return;
const es = new EventSource(`/api/email/events?token=${encodeURIComponent(token)}`);
es.onmessage = (ev) => {
try {
if ((JSON.parse(ev.data) as { type?: string }).type === 'new-mail') {
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
queryClient.invalidateQueries({ queryKey: ['email-messages-all-count'] });
queryClient.invalidateQueries({ queryKey: ['email-accounts'] });
}
} catch {
/* ignore non-JSON keepalives */
}
};
return () => es.close();
}, [queryClient]);
const [syncing, setSyncing] = useState(false);
const handleSync = async () => {
if (!syncableAccount) return;
setSyncing(true);
try {
const result = await client.post<{ ok: boolean; saved?: number }>(
`/email/accounts/${syncableAccount.id}/sync`,
{},
);
if (result.saved !== undefined) {
toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails');
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
} else {
toast.success('Sync started');
}
refetchAccounts();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to sync');
} finally {
setSyncing(false);
}
};
// Poll accounts while first sync is running (job-based), refresh email list when done
const prevSyncing = useRef(false);
useEffect(() => {
if (isSyncing) {
const interval = setInterval(refetchAccounts, 5000);
return () => clearInterval(interval);
}
if (prevSyncing.current && !isSyncing) {
queryClient.invalidateQueries({ queryKey: ['email-messages'] });
}
prevSyncing.current = isSyncing;
}, [isSyncing, queryClient]);
const messages = data?.messages ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / LIMIT));
// Arrow key navigation
useEffect(() => {
const handleKeyDown = (ev: KeyboardEvent) => {
if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown') return;
if (messages.length === 0) return;
ev.preventDefault();
const currentIndex = selectedId ? messages.findIndex((m) => m.id === selectedId) : -1;
let nextId: string | undefined;
if (ev.key === 'ArrowDown') {
const next = currentIndex + 1;
if (next < messages.length) nextId = messages[next]!.id;
} else {
const prev = currentIndex - 1;
if (prev >= 0) nextId = messages[prev]!.id;
}
if (nextId) {
// `replace` on purpose: sweeping down a folder with the arrow keys would otherwise stack one
// history entry per row, and Back would then walk the sweep instead of leaving the mailbox.
// A click is a real link and does push.
navigate(emailPath(nextId, qs), { replace: true });
document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' });
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [messages, selectedId, navigate, qs]);
if (isLoading) {
return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading emails...</div>;
}
// Show onboarding empty state only when no emails exist at all (never while searching)
const hasNoEmails = !isSearching && total === 0 && folder === 'inbox' && !allCount?.total;
if (hasNoEmails && !isLoading && page === 1) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm opacity-50">
<Mail className="h-8 w-8" />
{!hasAccounts ? (
<div className="flex flex-col items-center gap-2">
<span>Add an email account to get started</span>
<Button variant="outline" size="sm" asChild>
<Link to="/settings/integrations">Add Account</Link>
</Button>
</div>
) : syncableAccount ? (
<div className="flex flex-col items-center gap-2">
<span>No emails synced yet</span>
<Button variant="outline" size="sm" onClick={handleSync} disabled={syncing || isSyncing}>
{syncing || isSyncing ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-2 h-3.5 w-3.5" />
)}
Sync Now
</Button>
</div>
) : isSyncing ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Syncing emails...</span>
</div>
) : (
<span>No emails synced yet</span>
)}
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<div className="flex items-center gap-1">
{FOLDERS.map((f) => {
const Icon = f.icon;
const isActive = folder === f.key;
// Search-only `to`, so react-router keeps the current pathname — the open email survives a
// folder switch. Built from the live params rather than written flat, so anything else in
// the query string comes along.
const next = new URLSearchParams(searchParams);
next.set('folder', f.key);
return (
<Link
key={f.key}
to={{ search: `?${next}` }}
className={`flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
}`}
title={f.label}
aria-current={isActive ? 'true' : undefined}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{f.label}</span>
</Link>
);
})}
</div>
<span className="text-xs opacity-50">{total}</span>
{syncing || isSyncing ? (
<span title="Syncing..." className="flex shrink-0">
<Loader2 className="h-3.5 w-3.5 animate-spin opacity-50" />
</span>
) : syncableAccount ? (
<button
onClick={handleSync}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Sync emails"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : null}
<button
onClick={() => openCompose({})}
className="p-0.5 rounded hover:bg-black/10 transition-colors cursor-pointer shrink-0"
title="Compose"
>
<SquarePen className="h-3.5 w-3.5" />
</button>
{totalPages > 1 && (
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="text-xs opacity-60">
{page}/{totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5">
<Search className="h-3.5 w-3.5 shrink-0 opacity-40" />
<input
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search — try from:, subject:, has:attachment…"
className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
/>
{isSearching && (
<span className="text-xs opacity-50 shrink-0">
{total} result{total === 1 ? '' : 's'}
</span>
)}
{search && (
<button
onClick={() => setSearch('')}
className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer"
title="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{messages.length === 0 ? (
<div className="flex flex-1 items-center justify-center text-sm opacity-40">
{isSearching ? `No results for “${debouncedSearch}”` : 'No emails in this folder'}
</div>
) : (
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
{messages.map((msg: EmailSummary) => {
const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
return (
<Link
key={msg.id}
to={emailPath(msg.id, qs)}
data-email-id={msg.id}
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>
{msg.from}
</span>
{!!msg.threadCount && (
<span
className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60"
title={`${msg.threadCount} messages`}
>
{msg.threadCount}
</span>
)}
</div>
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
</div>
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
<div className="flex items-center gap-1.5 text-xs">
{!!msg.attachmentCount && (
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
<Paperclip className="h-3 w-3" />
{msg.attachmentCount}
</span>
)}
<span className="truncate opacity-50">{msg.snippet}</span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
};
@@ -1,238 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Mail, Reply, Paperclip } from 'lucide-react';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import type { EmailMessage, EmailThread } from 'types';
import { useComposer, replyDraft } from './Compose';
import { useSelectedEmailId } from './shared';
type OpenAttachment = {
filePath: string;
fileName: string;
root: string;
};
const senderName = (from: string) => from.replace(/\s*<[^>]+>$/, '').trim() || from;
const HtmlBody = ({ html }: { html: string }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; margin: 0; padding: 16px; color: #1a1a1a; background: #ffffff; }
a { color: #1a73e8; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${html}</body>
</html>
`);
doc.close();
// Auto-size to content so each message sits at its natural height inside the stacked thread.
const resize = () => {
if (doc.body) iframe.style.height = `${doc.body.scrollHeight + 8}px`;
};
resize();
doc.querySelectorAll('img').forEach((img) => img.addEventListener('load', resize));
const t = setTimeout(resize, 300);
return () => clearTimeout(t);
}, [html]);
return <iframe ref={iframeRef} className="w-full border-0" sandbox="allow-same-origin" title="Email body" />;
};
type MessagePanelProps = {
message: EmailMessage;
open: boolean;
onToggle: () => void;
onReply: () => void;
onOpenAttachment: (index: number) => void;
};
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
if (!open) {
return (
<button
onClick={onToggle}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer"
>
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>
{senderName(message.from)}
</span>
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
</button>
);
}
return (
<div className="flex flex-col">
<div className="flex items-start justify-between gap-3 px-4 py-2.5">
<button onClick={onToggle} className="min-w-0 flex-1 text-left cursor-pointer">
<div className="flex flex-col gap-0.5 text-sm opacity-80">
<div>
<span className="font-medium">{message.from}</span>
</div>
<div className="text-xs opacity-70">
<span className="font-medium">To:</span> {message.to}
</div>
{message.cc && (
<div className="text-xs opacity-70">
<span className="font-medium">Cc:</span> {message.cc}
</div>
)}
<div className="text-xs opacity-60">{new Date(message.date).toLocaleString()}</div>
</div>
</button>
<button
onClick={onReply}
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
title="Reply"
>
<Reply className="h-3.5 w-3.5" /> Reply
</button>
</div>
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pb-2">
{message.attachments.map((att, i) => (
<button
key={i}
className="cursor-pointer rounded bg-accent px-2 py-0.5 text-xs hover:bg-accent/80"
onClick={() => onOpenAttachment(i)}
>
{att.filename} ({Math.round(att.size / 1024)}KB)
</button>
))}
</div>
)}
{message.html ? (
<HtmlBody html={message.html} />
) : (
<pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>
)}
</div>
);
};
export const EmailReader = () => {
const client = useClient();
const queryClient = useQueryClient();
const [, openCompose] = useComposer();
// Reads the URL itself rather than being told by the list — the two panels never talk.
const selectedId = useSelectedEmailId();
const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const { data: thread, isLoading } = useQuery({
queryKey: ['email-thread', selectedId],
queryFn: () => client.get<EmailThread>(`/email/thread/${selectedId}`),
enabled: !!selectedId,
});
// Whenever the thread changes, expand just the latest message (Gmail-style).
useEffect(() => {
const messages = thread?.messages;
if (messages?.length) setExpanded(new Set([messages[messages.length - 1]!.id]));
}, [thread?.id, thread?.messages.length]);
// Mark the whole conversation as read when opened.
useEffect(() => {
if (!thread || !selectedId || !thread.messages.some((m: EmailMessage) => !m.read)) return;
client
.patch(`/email/thread/${selectedId}/read`)
.then(() => queryClient.invalidateQueries({ queryKey: ['email-messages'] }))
.catch(() => {});
}, [thread?.id]);
const extractAttachment = async (messageId: string, index: number) => {
const result = await client.post<OpenAttachment>(`/email/messages/${messageId}/attachments/${index}/extract`);
setOpenAttachment(result);
};
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
if (!selectedId) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Mail className="h-10 w-10" />
Select an email to read
</div>
);
}
if (isLoading) {
return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading...</div>;
}
if (!thread || thread.messages.length === 0) return null;
const latest = thread.messages[thread.messages.length - 1]!;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<h2 className="text-lg font-semibold">{thread.subject}</h2>
<button
onClick={() => openCompose(replyDraft(latest))}
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
title="Reply"
>
<Reply className="h-3.5 w-3.5" /> Reply
</button>
</div>
<div className="flex-1 divide-y divide-white/10 overflow-auto">
{thread.messages.map((m: EmailMessage) => (
<MessagePanel
key={m.id}
message={m}
open={expanded.has(m.id)}
onToggle={() => toggle(m.id)}
onReply={() => openCompose(replyDraft(m))}
onOpenAttachment={(index) => extractAttachment(m.id, index)}
/>
))}
</div>
<Dialog open={!!openAttachment} onOpenChange={(open) => !open && setOpenAttachment(null)}>
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
{openAttachment && (
<FileViewerProvider
filePath={openAttachment.filePath}
fileName={openAttachment.fileName}
root={openAttachment.root}
>
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
<FileViewerHeader />
</div>
<div className="min-h-0 flex-1">
<FileViewerBody />
</div>
</FileViewerProvider>
)}
</DialogContent>
</Dialog>
</div>
);
};
@@ -1,54 +0,0 @@
import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { useIsMobile } from 'hooks/useIsMobile';
import { defaultLayout } from './defaultLayout';
import { EmailList } from './EmailList';
import { EmailReader } from './EmailReader';
import { ComposeModal } from './Compose';
import { useSelectedEmailId } from './shared';
const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite email database available via the "email_db" tool — use it for all email queries (search, count, stats, aggregations, deletions) unless the user explicitly asks you to use Gmail. Do not use the Gmail integration for questions about existing emails.`;
// The prefix is this screen's, so this screen mounts the chat panel that gets it — rather than handing
// the string to the workspace to courier down to whichever app happens to read it. The panel id is the
// one in `defaultLayout`, and the layout is locked, so it is the same panel either way.
const EmailChatPanel = () => <ChatPanelWrapper panelId="email-chat" promptPrefix={PROMPT_PREFIX} />;
export const EmailScreen = () => {
const isMobile = useIsMobile();
const navigate = useNavigate();
// No `<Navigate>` guard: unlike a section route, the bare /email is a real state — the list with
// nothing open — exactly as /chat and /jobs are. An id that doesn't resolve is the reader's problem,
// not a reason to rewrite the address the user is looking at.
const selectedId = useSelectedEmailId();
const workspace = useDashboardState<LayoutNode>('screens/email', defaultLayout);
const components: PanelComponents = useMemo(
() => ({
'email-list': EmailList,
'email-reader': EmailReader,
'email-chat': EmailChatPanel,
}),
[],
);
const mobilePanelId = isMobile && selectedId ? 'email-reader' : undefined;
// Back on mobile closes the email, which is now just navigating to the bare route.
const onMobileBack = useCallback(() => navigate('/email'), [navigate]);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
components={components}
mobilePanelId={mobilePanelId}
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/>
<ComposeModal />
</div>
);
};
@@ -1,22 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'email-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'email-list', appType: null }, size: 25 },
{
node: {
type: 'group',
id: 'email-right',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'email-reader', appType: null }, size: 60 },
{ node: { type: 'panel', id: 'email-chat', appType: 'officerdev/chat' }, size: 40 },
],
},
size: 75,
},
],
};
@@ -1 +0,0 @@
export { EmailScreen } from './EmailScreen';
@@ -1,13 +0,0 @@
import { useParams } from 'react-router';
// Which email is open is `/email/:emailId`. The route pair already existed, but the URL was a mirror
// rather than the state: the selection lived in an `EMAIL_SELECTED` global and two effects copied it
// back and forth with the route. That round-trip is what kept a row a `<button>` — nothing to link to,
// no cmd-click, no middle-click — and it meant Back raced the effect that had just written the URL
// with `replace`. The param is the state now; there is nothing to keep in sync.
/** `search` carries the folder you are reading in (`?folder=sent`), which a bare pathname would drop. */
export const emailPath = (id: string, search = '') => `/email/${encodeURIComponent(id)}${search}`;
/** The open email, or null on the bare `/email` route — which is a real state, not one to redirect away. */
export const useSelectedEmailId = (): string | null => useParams<{ emailId?: string }>().emailId ?? null;
@@ -1,509 +0,0 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2, Mail, Server, KeyRound, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useJobs } from 'hooks/useJobs';
type EmailAccountRow = {
id: number;
provider: string;
email: string;
displayName: string | null;
enabled: boolean;
status: string;
createdAt: string;
};
type ProviderChoice = 'gmail-oauth' | 'gmail-password' | 'imap';
type FormState = {
email: string;
displayName: string;
password: string;
imapHost: string;
imapPort: string;
imapSecure: boolean;
};
const INITIAL_FORM: FormState = {
email: '',
displayName: '',
password: '',
imapHost: '',
imapPort: '993',
imapSecure: true,
};
export const EmailAccounts = () => {
const client = useClient();
const { user } = useAuth();
const { jobs } = useJobs({ type: 'email-sync' });
const [accounts, setAccounts] = useState<EmailAccountRow[]>([]);
const [loading, setLoading] = useState(true);
const [showPicker, setShowPicker] = useState(false);
const [activeForm, setActiveForm] = useState<ProviderChoice | null>(null);
const [form, setForm] = useState<FormState>(INITIAL_FORM);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<number | null>(null);
const [syncing, setSyncing] = useState<number | null>(null);
const fetchAccounts = () => {
client
.get<EmailAccountRow[]>('/email/accounts')
.then(setAccounts)
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => {
fetchAccounts();
}, []);
// Poll accounts while any are queued/syncing to pick up status changes
const hasSyncingAccount = accounts.some((a) => a.status === 'syncing' || a.status === 'queued');
useEffect(() => {
if (!hasSyncingAccount) return;
const interval = setInterval(fetchAccounts, 5000);
return () => clearInterval(interval);
}, [hasSyncingAccount]);
const resetForm = () => {
setActiveForm(null);
setShowPicker(false);
setForm(INITIAL_FORM);
};
const handlePickProvider = (choice: ProviderChoice) => {
setShowPicker(false);
setActiveForm(choice);
if (choice === 'gmail-oauth' || choice === 'gmail-password') {
setForm({ ...INITIAL_FORM, imapHost: 'imap.gmail.com', imapPort: '993', imapSecure: true });
} else {
setForm(INITIAL_FORM);
}
};
const handleGmailOAuth = async () => {
setSaving(true);
try {
const status = await client.get<{ connected: boolean; email: string | null }>('/integrations/google/status');
if (!status.connected) {
const params = new URLSearchParams({ token: client.token ?? '', origin: window.location.origin });
window.location.href = `/api/integrations/google/authorize?${params.toString()}`;
return;
}
const result = await client.post<{ id: number; provider: string; email: string }>('/email/accounts', {
provider: 'gmail',
email: status.email,
displayName: form.displayName || undefined,
imapHost: 'imap.gmail.com',
imapPort: 993,
imapSecure: true,
authType: 'oauth',
credentials: { userIntegrationId: true },
});
toast.success(`Added ${result.email}`);
resetForm();
fetchAccounts();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add Gmail account');
} finally {
setSaving(false);
}
};
const handlePasswordSubmit = async () => {
if (!form.email.trim() || !form.password.trim()) return;
setSaving(true);
try {
const provider = form.imapHost === 'imap.gmail.com' ? 'gmail' : 'imap';
const result = await client.post<{ id: number; provider: string; email: string }>('/email/accounts', {
provider,
email: form.email.trim(),
displayName: form.displayName.trim() || undefined,
imapHost: form.imapHost,
imapPort: Number(form.imapPort),
imapSecure: form.imapSecure,
authType: 'password',
credentials: { password: form.password },
});
toast.success(`Added ${result.email}`);
resetForm();
fetchAccounts();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add account');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
setDeleting(id);
try {
await client.delete(`/email/accounts/${id}`);
setAccounts((prev) => prev.filter((a) => a.id !== id));
toast.success('Account removed');
} catch {
toast.error('Failed to remove account');
} finally {
setDeleting(null);
}
};
const handleSync = async (id: number) => {
setSyncing(id);
try {
const result = await client.post<{ ok: boolean; saved?: number }>(`/email/accounts/${id}/sync`, {});
if (result.saved !== undefined) {
toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails');
} else {
toast.success('Sync started');
setAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'queued' } : a)));
}
fetchAccounts();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to sync');
} finally {
setSyncing(null);
}
};
if (loading) return null;
return (
<div className="grid gap-4">
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Email Accounts</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
Connect email accounts for syncing your inbox. Supports Gmail and any IMAP provider.
</p>
</div>
{/* Account list */}
{accounts.length > 0 && (
<div className="grid gap-2">
{accounts.map((account) => {
const accountJob = jobs.find(
(j) =>
(j.status === 'queued' || j.status === 'running') &&
(j.meta as Record<string, unknown> | undefined)?.emailAccountId === account.id,
);
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
return (
<div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="flex items-center gap-3">
<ProviderIcon provider={account.provider} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground truncate">
{account.displayName ?? account.email}
</p>
{account.displayName && (
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{account.email}</p>
)}
</div>
<StatusBadge status={account.status} />
{account.status === 'queued' && (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">Waiting...</span>
)}
{(account.status === 'connected' || account.status === 'synced') && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleSync(account.id)}
disabled={syncing === account.id}
className="h-7 text-xs cursor-pointer gap-1.5"
>
{syncing === account.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Sync
</Button>
)}
<button
onClick={() => handleDelete(account.id)}
disabled={deleting === account.id || account.status === 'syncing' || account.status === 'queued'}
className="text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
{deleting === account.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
{/* Sync progress */}
{account.status === 'syncing' && progress && (
<div className="mt-2 flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
<p className="text-xs text-duck-dark/60 dark:text-foreground/60 truncate">
{progress.label ?? 'Syncing...'}
</p>
</div>
)}
{account.status === 'syncing' && !progress && (
<div className="mt-2 flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">Syncing...</p>
</div>
)}
</div>
);
})}
</div>
)}
{/* Provider picker */}
{showPicker && !activeForm && (
<div className="grid gap-2">
<button
onClick={() => handlePickProvider('gmail-oauth')}
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
>
<Mail className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail (OAuth)</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">Uses your existing Google connection</p>
</div>
</button>
<button
onClick={() => handlePickProvider('gmail-password')}
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
>
<KeyRound className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail (App Password)</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Direct IMAP with a Google App Password
</p>
</div>
</button>
<button
onClick={() => handlePickProvider('imap')}
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
>
<Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">IMAP Account</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">Any email provider with IMAP access</p>
</div>
</button>
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel
</Button>
</div>
)}
{/* Gmail OAuth form */}
{activeForm === 'gmail-oauth' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (OAuth)</p>
<input
type="text"
value={form.displayName}
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
placeholder="Display name (optional)"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel
</Button>
<Button type="button" onClick={handleGmailOAuth} disabled={saving} className="h-9 flex-1 cursor-pointer">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Connect & Add'}
</Button>
</div>
</div>
)}
{/* Gmail App Password form */}
{activeForm === 'gmail-password' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (App Password)</p>
<div className="text-xs text-duck-dark/50 dark:text-foreground/50 grid gap-1.5">
<p>To create an App Password:</p>
<ol className="list-decimal ml-4 grid gap-0.5">
<li>
Go to{' '}
<a
href="https://myaccount.google.com/apppasswords"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
>
Google App Passwords
</a>
</li>
<li>You may need to enable 2-Step Verification first</li>
<li>Enter a name (e.g. "Officer") and click Create</li>
<li>Copy the 16-character password and paste it below</li>
</ol>
</div>
<input
type="email"
value={form.email}
onChange={(ev) => setForm({ ...form, email: ev.target.value })}
placeholder="your@gmail.com"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<input
type="text"
value={form.displayName}
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
placeholder="Display name (optional)"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<input
type="password"
value={form.password}
onChange={(ev) => setForm({ ...form, password: ev.target.value })}
placeholder="xxxx xxxx xxxx xxxx"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel
</Button>
<Button
type="button"
onClick={handlePasswordSubmit}
disabled={!form.email.trim() || !form.password.trim() || saving}
className="h-9 flex-1 cursor-pointer"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
</Button>
</div>
</div>
)}
{/* Generic IMAP form */}
{activeForm === 'imap' && (
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add IMAP Account</p>
<input
type="email"
value={form.email}
onChange={(ev) => setForm({ ...form, email: ev.target.value })}
placeholder="you@example.com"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<input
type="text"
value={form.displayName}
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
placeholder="Display name (optional)"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<div className="grid grid-cols-[1fr_auto_auto] gap-2">
<input
type="text"
value={form.imapHost}
onChange={(ev) => setForm({ ...form, imapHost: ev.target.value })}
placeholder="imap.example.com"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<input
type="number"
value={form.imapPort}
onChange={(ev) => setForm({ ...form, imapPort: ev.target.value })}
placeholder="993"
className="h-9 w-20 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<label className="flex items-center gap-1.5 text-xs text-duck-dark/60 dark:text-foreground/60 cursor-pointer">
<input
type="checkbox"
checked={form.imapSecure}
onChange={(ev) => setForm({ ...form, imapSecure: ev.target.checked })}
className="cursor-pointer"
/>
SSL
</label>
</div>
<input
type="password"
value={form.password}
onChange={(ev) => setForm({ ...form, password: ev.target.value })}
placeholder="Password"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
Cancel
</Button>
<Button
type="button"
onClick={handlePasswordSubmit}
disabled={!form.email.trim() || !form.password.trim() || !form.imapHost.trim() || saving}
className="h-9 flex-1 cursor-pointer"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
</Button>
</div>
</div>
)}
{/* Add account button */}
{!showPicker && !activeForm && (
<Button
type="button"
variant="outline"
onClick={() => setShowPicker(true)}
className="w-full h-11 cursor-pointer gap-2"
>
<Plus className="h-4 w-4" />
Add Account
</Button>
)}
</div>
);
};
const StatusBadge = ({ status }: { status: string }) => {
switch (status) {
case 'connected':
return (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
Connected
</span>
);
case 'queued':
return (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600 dark:text-orange-400">
Queued
</span>
);
case 'syncing':
return (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
Syncing
</span>
);
case 'synced':
return (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
Synced
</span>
);
default:
return null;
}
};
const ProviderIcon = ({ provider }: { provider: string }) => {
switch (provider) {
case 'gmail':
return <Mail className="h-4 w-4 text-red-500 shrink-0" />;
case 'outlook':
return <Mail className="h-4 w-4 text-blue-500 shrink-0" />;
default:
return <Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60 shrink-0" />;
}
};
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, Mail, CalendarDays } from 'lucide-react';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, CalendarDays } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
@@ -11,7 +11,6 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { BrowserRelay } from './BrowserRelay';
import { ApifyConfig } from './ApifyConfig';
import { EmailAccounts } from './EmailAccounts';
import { DavAppPasswords } from './DavAppPasswords';
import { ApiKeys } from './ApiKeys';
@@ -35,13 +34,8 @@ const enterpriseSections: SettingsSection[] = [
];
const personalSections: SettingsSection[] = [
{
key: 'email-accounts',
icon: Mail,
title: 'Email',
description: 'Connect email accounts for inbox sync',
content: <EmailAccounts />,
},
// Email account management left with the feature on 2026-08-18 (`plugins/email/`). A plugin contributes
// panels, not settings sections, so it now lives inside the email workspace itself.
// Google OAuth is left in place (code + DB) but hidden — email now uses an app password over IMAP,
// so there's no reason to reconnect Google here. Re-enable this section to bring it back.
// {
@@ -26,7 +26,6 @@ export * from './CodeEditor';
export * from './ChatHistory';
export * from './Dashboards';
export * from './Terminal';
export * from './Email';
export * from './Browser';
export * from './Desktop';
export * from './Jobs';
@@ -17,7 +17,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/settings/system'), title: 'System' },
{ match: (p) => p.startsWith('/settings'), title: 'Settings' },
{ match: (p) => p.startsWith('/chat'), title: 'Chat' },
{ match: (p) => p.startsWith('/email'), title: 'Email' },
{ match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
@@ -1,9 +0,0 @@
export {
getEmailAccounts,
getEmailAccount,
createEmailAccount,
deleteEmailAccount,
updateEmailAccountStatus,
updateEmailAccountSyncMeta,
getAllSyncedAccounts,
} from './queries';
@@ -1,41 +0,0 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { emailAccounts } from './schema';
import type { EmailAccountInsert, EmailAccountSelect } from '../types';
export async function getEmailAccounts(userId: number): Promise<EmailAccountSelect[]> {
return db.select().from(emailAccounts).where(eq(emailAccounts.userId, userId));
}
export async function getEmailAccount(id: number): Promise<EmailAccountSelect | undefined> {
const [row] = await db.select().from(emailAccounts).where(eq(emailAccounts.id, id));
return row;
}
export async function createEmailAccount(params: EmailAccountInsert): Promise<EmailAccountSelect> {
const [row] = await db.insert(emailAccounts).values(params).returning();
return row!;
}
export async function deleteEmailAccount(id: number, userId: number): Promise<boolean> {
const [row] = await db
.delete(emailAccounts)
.where(and(eq(emailAccounts.id, id), eq(emailAccounts.userId, userId)))
.returning({ id: emailAccounts.id });
return !!row;
}
export async function updateEmailAccountStatus(id: number, status: string): Promise<void> {
await db.update(emailAccounts).set({ status }).where(eq(emailAccounts.id, id));
}
export async function updateEmailAccountSyncMeta(id: number, syncMeta: Record<string, unknown>): Promise<void> {
await db.update(emailAccounts).set({ syncMeta }).where(eq(emailAccounts.id, id));
}
export async function getAllSyncedAccounts(): Promise<EmailAccountSelect[]> {
return db
.select()
.from(emailAccounts)
.where(and(eq(emailAccounts.status, 'synced'), eq(emailAccounts.enabled, true)));
}
@@ -1,35 +0,0 @@
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
export const emailAccounts = pgTable(
'email_accounts',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
provider: text('provider').notNull(),
email: text('email').notNull(),
displayName: text('display_name'),
imapHost: text('imap_host').notNull(),
imapPort: integer('imap_port').notNull(),
imapSecure: boolean('imap_secure').notNull().default(true),
authType: text('auth_type').notNull(),
credentials: jsonb('credentials').notNull().default({}),
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
status: text('status').notNull().default('connected'),
/**
* LEGACY, read-only. The sync position now lives in the account's own `emails.db`
* (`sidecar/email/sync-meta.ts`), so the file is self-describing and an account can be moved to
* another machine by copying it.
*
* Kept because it is the one-time backfill source for every account created before that move; the
* first sync after it migrates these keys into SQLite and nothing writes here again. Dropping it
* would strand any account that has not synced since.
*/
syncMeta: jsonb('sync_meta').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_email_accounts_user_email').on(table.userId, table.email)],
);
-1
View File
@@ -44,7 +44,6 @@ export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
export * from './email';
export * from './invoiceshelf';
export * from './jellyfin';
export * from './notify';
-1
View File
@@ -44,7 +44,6 @@ export * from './service-connections/schema'; // service_connections
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
// export * from './email/schema'; // email_accounts officer-email
// export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './photos/schema'; // photos_config officer-photos
-4
View File
@@ -14,7 +14,6 @@ import type * as Schema from './schema';
//
// Same move the query modules made when the split landed — import the feature's schema
// directly — which is what lets a table leave the aggregator without breaking anything.
import type * as EmailSchema from './email/schema';
import type * as NotifySchema from './notify/schema';
// ── Auth ──
@@ -63,9 +62,6 @@ export type ScreenInsert = typeof Schema.screens.$inferInsert;
// ── Email ──
export type EmailAccountSelect = typeof EmailSchema.emailAccounts.$inferSelect;
export type EmailAccountInsert = typeof EmailSchema.emailAccounts.$inferInsert;
// ── Server ──
export type ServerConfigSelect = typeof Schema.serverConfig.$inferSelect;
+7 -13
View File
@@ -15,11 +15,10 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
import { attachedFilePaths } from './attached-files';
import {
getUserSettings,
getEmailAccounts,
getChatEventsSince,
appendChatEvent,
getUserById,
@@ -124,16 +123,11 @@ export const resolveBaseCwd = (home: string, cwd?: string) => resolveCwd(home, c
// DATA_PATH/<owner>/email_accounts/<accountEmail>
// `accountEmail` will come from the account selector (msg.contextId) later; for now default to the
// owner's first enabled account. Falls back to the email_accounts root if there are no accounts.
async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?: string): Promise<string> {
let account = accountEmail?.trim();
if (!account) {
try {
const accounts = await getEmailAccounts(userId);
account = (accounts.find((a) => a.enabled) ?? accounts[0])?.email;
} catch (err) {
logger.error('Failed to resolve email account for chat cwd', { userId, error: String(err) });
}
}
async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promise<string> {
// Found on disk, never queried. Email is a plugin and the platform may not import its queries; the
// directory layout is already the platform's, so reading it answers the question with no coupling and
// nothing to throw when the plugin is not installed.
const account = accountEmail?.trim() || findEmailAccountName(ownerEmail);
const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
mkdirSync(dir, { recursive: true });
return dir;
@@ -147,7 +141,7 @@ async function resolveChatCwd(
userId: number,
home: string,
): Promise<string> {
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId);
if (msg.context === 'chat') {
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
// The caller's own home, for everyone.
-22
View File
@@ -1,22 +0,0 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// /api/email/* — auth, then forward to officer-email. No routes of its own and no mail knowledge: not a
// message, not a folder, not an IMAP or SMTP credential.
//
// The mail store, the 14 HTTP routes, account CRUD, resync and IMAP validation all used to live in this
// directory — ~1,800 lines against a 314-line sidecar, with the sidecar importing BACK into the platform
// to reach them. They are the sidecar's now.
//
// A from-scratch mailbox sync holds a proxied request open well past the 60s idle default, hence the
// timeout; it applies to the whole prefix because the proxy must not know which routes are slow.
const proxy = createSidecarProxy({
name: 'email',
prefix: '/api/email',
timeoutSeconds: 1800,
});
export const emailRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getEmailServerUrl = proxy.getHttpUrl;
@@ -303,18 +303,5 @@ export const googleCallbackHandler = async (ctx: any) => {
},
});
// Auto-add /email to dock
try {
const existing = await getDockPaths(dbUser.id);
const paths = existing ?? ['/', '/files', '/automation', '/dashboards', '/chat'];
if (!paths.includes('/email')) {
paths.push('/email');
await setDockPaths(dbUser.id, paths);
}
} catch {
// Non-critical — don't block the OAuth flow
}
return ctx.redirect('/settings/integrations?google=success');
};
+2 -13
View File
@@ -374,19 +374,8 @@ export const CATALOGUE: CatalogueEntry[] = [
// place to type the same URL and API key.
// ── Nothing to reach: configuration only ───────────────────────────────────────────────────────────
{
id: 'email',
ui: { name: 'Email', icon: 'Mail', color: '#ef4444', rootRoute: '/email', routes: ['/email'] },
process: 'officer-email',
label: 'Email',
summary: 'Your IMAP accounts, synced and searchable',
members: 'none',
modes: ['config'],
permission: 'email',
// Deliberately empty: accounts are added from /email, which already has a working multi-account
// form. Duplicating it here would be a second place to maintain the same credentials.
configFields: [],
},
// Email was here until 2026-08-18, when it became `plugins/email/`. Removing it is not tidying — it is
// the headscale bug two comments up, exactly, and it would have fired on the plugin's first install.
// Music was here until 2026-08-15, when it became `plugins/music/`. Removing it was not tidying — it
// was the headscale bug above, exactly, and it would have fired on the first install.
//
+2 -2
View File
@@ -8,7 +8,7 @@ import { byId } from './catalogue';
const photos = byId('photos')!; // existing|provisioned, members: 'accounts'
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
const email = byId('email')!; // config only
const configOnly = byId('notify')!; // config only
/** Records what was actually called, so a test can assert on absence as well as presence. */
function spyEffects(over: Partial<InstallEffects> = {}) {
@@ -51,7 +51,7 @@ describe('planSteps', () => {
});
it('has nothing to connect for a config-only install', () => {
expect(planSteps(email, 'config')).toEqual(['preflight', 'schema', 'assets', 'process']);
expect(planSteps(configOnly, 'config')).toEqual(['preflight', 'schema', 'assets', 'process']);
});
it('always starts with preflight', () => {
+1 -1
View File
@@ -17,6 +17,6 @@ describe('preflight is per mode, not per entry', () => {
});
it('allows a config-only sidecar regardless', async () => {
expect(await preflight(byId('email')!, 'config')).toEqual({ ok: true });
expect(await preflight(byId('notify')!, 'config')).toEqual({ ok: true });
});
});
+34 -1
View File
@@ -1,5 +1,5 @@
import { join, resolve } from 'node:path';
import { chmodSync, mkdirSync, readFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
// ── The install root, and why it is derived rather than configured ──
@@ -197,6 +197,39 @@ export const getEmailDbPath = (ownerEmail: string, accountEmail: string) =>
export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
join(getEmailAccountsDir(ownerEmail), 'attachment_cache');
/**
* Which mail account's store to use, found on DISK rather than by asking the database.
*
* Email is a plugin (`plugins/email/`) as of 2026-08-18, and the platform may not import a plugin's
* queries — that direction is the one the plugin system forbids, and here it is also the direction that has
* already broken this server: `getEmailAccounts` was awaited at the top level of the agent sidecar, and
* when the table went away on 2026-08-13 the rejection took `officer-agent` into a PM2 restart loop with
* every live chat session inside it (`fbb6d6c7`).
*
* Two callers still legitimately need an answer — the agent's `email_db` MCP tool, and a chat opened
* against a mailbox — and neither needs to know anything about mail to get one. The directory layout is
* already the platform's (see `USER_DIRS`), so reading it answers the question with no coupling: no import,
* no query, and nothing to throw if the plugin is not installed.
*
* Newest by mtime, because the most recently synced account is the one a fresh question is most likely
* about. Returns the DIRECTORY NAME (the account's address), which is what both callers compose with.
*/
export function findEmailAccountName(ownerEmail: string): string | null {
try {
const root = getEmailAccountsDir(ownerEmail);
const candidates = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== 'attachment_cache')
.map((entry) => ({ name: entry.name, db: join(root, entry.name, 'emails.db') }))
.filter((candidate) => existsSync(candidate.db))
.map((candidate) => ({ ...candidate, at: statSync(candidate.db).mtimeMs }))
.sort((a, b) => b.at - a.at);
return candidates[0]?.name ?? null;
} catch {
// No directory, no permission, no plugin. All of them mean "no account", which is a normal state.
return null;
}
}
// Sanitises a display username or email into a bare, lowercase, shell-safe token. The name and the
// 32-char Linux limit are the last trace of the per-container architecture, where this really did name
// a Linux user inside the user's container. Nothing creates a Linux user now — the value is carried
-2
View File
@@ -48,7 +48,6 @@ import { activityRouter } from './api/activity/router';
import { dockRouter } from './api/dock/dock';
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
import { queueRouter } from './api/queue/queue';
// import { emailRouter } from './api/email/router';
// The browser relay is switched off — see server.tsx. Restoring this mount means restoring the
// registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot.
// import { browserRouter } from './api/browser/router';
@@ -153,7 +152,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/dock', dockRouter],
['/integrations', integrationsRouter],
['/queue', queueRouter],
// ['/email', emailRouter], // plugin — switched off 2026-08-13
// ['/browser', browserRouter], // switched off — see server.tsx
['/bug-report', bugReportRouter],
['/agent-status', agentStatusRouter],
-9
View File
@@ -199,15 +199,6 @@ const CORE_REGISTRY: Permission[] = [
// App passwords are minted for and revoked by their own owner; `/dav` holds nothing shared.
personal: ['/'],
},
{
key: 'email',
label: 'Email',
description: 'Mail accounts and messages',
kind: 'app',
// api: ['/email'], // plugin — switched off 2026-08-13
api: [],
routes: ['/email'],
},
{
key: 'notify',
label: 'Notifications',
+9 -17
View File
@@ -16,8 +16,8 @@ import { setMcpConfigPath } from './claude-manager';
import * as claudeManager from './claude-manager';
import { createSidecarConnector } from '../connect';
import { sign } from '../../jwt';
import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb';
import { DATA_PATH } from '../../data-path';
import { getUserByEmail, getOwnerUser } from 'officerdb';
import { DATA_PATH, findEmailAccountName } from '../../data-path';
import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
@@ -85,22 +85,14 @@ const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.u
const homeDir = homedir();
const globalToolsDir = join(DATA_PATH, 'tools');
// The email_db MCP tool reads one account's SQLite store; match the API's choice (first enabled).
// The email_db MCP tool reads one account's SQLite store; found on disk, never queried.
//
// Guarded for the same reason `resolveOwner` above is, and it is the same bug in a second place: this
// is a top-level await, so a query that THROWS rejects it and exits the process — straight into the PM2
// restart loop, taking every live agent session with it. `resolveOwner` was hardened after that cost a
// session on 2026-08-10; this line has the identical shape and was not.
//
// It throws for real now: email is a plugin, so `email_accounts` is commented out of the schema
// (officerdb/src/schema.ts) and does not exist on a core install. An agent must start without it — the
// email tool is one tool, not a prerequisite for chat.
const emailAccounts = await getEmailAccounts(dbUser.id).catch(() => {
console.warn('[agent] no email accounts (the email plugin is not installed) — the email_db tool will be idle');
return [] as Awaited<ReturnType<typeof getEmailAccounts>>;
});
const emailAccount = emailAccounts.find((a) => a.enabled) ?? emailAccounts[0];
const emailDbRel = join('email_accounts', emailAccount?.email ?? 'none', 'emails.db');
// This used to `await getEmailAccounts(dbUser.id)` at the top level. Email is a plugin now and the platform
// may not import its queries — and that import is also what put this process into a PM2 restart loop when
// the table went away (`fbb6d6c7`), taking every live chat session with it. A directory read cannot throw
// the same way and cannot depend on the plugin being installed.
const emailAccountName = findEmailAccountName(email);
const emailDbRel = join('email_accounts', emailAccountName ?? 'none', 'emails.db');
const userToolsDir = join(DATA_PATH, email, 'tools');
// ── Path setup ──
-264
View File
@@ -1,264 +0,0 @@
import { createRouter } from '../../create-router';
import { BAD_REQUEST, NOT_FOUND } from '../../custom-errors';
import {
getEmailAccounts,
getEmailAccount,
createEmailAccount,
deleteEmailAccount,
updateEmailAccountStatus,
} from 'officerdb';
import { getValidGoogleAccessToken } from '../../api/integrations/google-auth';
import { validateImapConnection } from './imap-validate';
import { startSync, isSyncing, getSyncStates } from './sync-runner';
import { imapSyncSteps } from './imap-sync';
import { gmailSyncSteps } from './gmail-api';
import { openEmailDb } from './store';
import { hasSyncedBefore } from './sync-meta';
import { performResync } from './resync';
type CreateAccountBody = {
provider: string;
email: string;
displayName?: string;
imapHost: string;
imapPort: number;
imapSecure: boolean;
authType: string;
credentials: Record<string, unknown>;
};
type ValidateBody = {
imapHost: string;
imapPort: number;
imapSecure: boolean;
authType: string;
email: string;
credentials: Record<string, unknown>;
};
export const accountsRouter = createRouter();
accountsRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const accounts = await getEmailAccounts(user.id);
// Check for stale syncing/queued accounts with no active job
const staleIds: number[] = [];
const hasActiveAccounts = accounts.some((a) => a.status === 'syncing' || a.status === 'queued');
let activeJobAccountIds = new Set<number>();
if (hasActiveAccounts) {
// In-process now: a running sync is one this process started, not a queued job row. An account marked
// syncing with nothing running is stale — the usual cause is a restart mid-sync.
activeJobAccountIds = new Set(
getSyncStates()
.filter((s) => s.status === 'running')
.map((s) => s.accountId),
);
for (const a of accounts) {
if ((a.status === 'syncing' || a.status === 'queued') && !activeJobAccountIds.has(a.id)) {
staleIds.push(a.id);
}
}
// Reset stale accounts in background
if (staleIds.length > 0) {
for (const id of staleIds) {
updateEmailAccountStatus(id, 'connected').catch(() => {});
}
}
}
return ctx.json(
accounts.map((a) => ({
id: a.id,
provider: a.provider,
email: a.email,
displayName: a.displayName,
enabled: a.enabled,
status: staleIds.includes(a.id) ? 'connected' : a.status,
createdAt: a.createdAt,
})),
);
});
accountsRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as CreateAccountBody;
if (!body.provider || !body.email || !body.imapHost || !body.imapPort || !body.authType) {
throw BAD_REQUEST('Missing required fields');
}
const authResult = await resolveAuth(user.id, body.authType, body.email, body.credentials);
if (!authResult.ok) throw BAD_REQUEST(authResult.error);
const validation = await validateImapConnection({
host: body.imapHost,
port: body.imapPort,
secure: body.imapSecure,
user: body.email,
...authResult.auth,
});
if (!validation.ok) throw BAD_REQUEST(`IMAP connection failed: ${validation.error}`);
const account = await createEmailAccount({
userId: user.id,
provider: body.provider,
email: body.email,
displayName: body.displayName,
imapHost: body.imapHost,
imapPort: body.imapPort,
imapSecure: body.imapSecure,
authType: body.authType,
credentials: body.credentials,
});
return ctx.json({ id: account.id, provider: account.provider, email: account.email }, 201);
});
accountsRouter.delete('/:id', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const deleted = await deleteEmailAccount(id, user.id);
if (!deleted) throw NOT_FOUND('Account not found');
return ctx.json({ ok: true });
});
accountsRouter.post('/:id/sync', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
const account = await getEmailAccount(id);
if (!account || account.userId !== user.id) throw NOT_FOUND('Account not found');
if (account.status === 'queued') throw BAD_REQUEST('Sync is already queued');
if (account.status === 'syncing') throw BAD_REQUEST('Account is already syncing');
// Determine if this is a first sync or resync
// One answer for both providers now. This used to fork: Gmail read SQLite, IMAP read the Postgres
// column, which is how the two drifted apart in the first place.
let isFirstSync = true;
{
const db = openEmailDb(user.email, account.email);
try {
isFirstSync = !hasSyncedBefore(db, account.syncMeta as Record<string, unknown>);
} finally {
db.close();
}
}
// Resync: call directly, no job queue
if (!isFirstSync) {
await updateEmailAccountStatus(id, 'syncing');
try {
const result = await performResync({ accountId: id, userEmail: user.email, userId: user.id });
await updateEmailAccountStatus(id, 'synced');
return ctx.json({ ok: true, saved: result.saved });
} catch (err) {
await updateEmailAccountStatus(id, 'synced').catch(() => {});
throw err;
}
}
// First sync: enqueue a job
const authResult = await resolveAuth(
user.id,
account.authType,
account.email,
account.credentials as Record<string, unknown>,
);
if (!authResult.ok) throw BAD_REQUEST(authResult.error);
await updateEmailAccountStatus(id, 'queued');
// Gmail API sync needs OAuth; a gmail account with an app password syncs over IMAP instead.
const useGmailApi = account.provider === 'gmail' && account.authType === 'oauth';
if (isSyncing(id)) return ctx.json({ ok: true, alreadyRunning: true });
startSync({
accountId: id,
// The OWNER's email, not the account's — the store path is keyed by it.
ownerEmail: user.email,
steps: useGmailApi ? gmailSyncSteps : imapSyncSteps,
onDone: (ok) => updateEmailAccountStatus(id, ok ? 'synced' : 'error').then(() => undefined),
meta: {
emailAccountId: id,
userEmail: user.email,
account: {
id: account.id,
userId: account.userId,
email: account.email,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapSecure: account.imapSecure,
provider: account.provider,
authType: account.authType,
credentials: account.credentials,
},
imapAuth: { user: account.email, ...authResult.auth },
},
});
return ctx.json({ ok: true }, 201);
});
accountsRouter.post('/validate', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as ValidateBody;
if (!body.imapHost || !body.imapPort || !body.authType || !body.email) {
throw BAD_REQUEST('Missing required fields');
}
const authResult = await resolveAuth(user.id, body.authType, body.email, body.credentials);
if (!authResult.ok) return ctx.json({ ok: false, error: authResult.error });
const result = await validateImapConnection({
host: body.imapHost,
port: body.imapPort,
secure: body.imapSecure,
user: body.email,
...authResult.auth,
});
return ctx.json(result);
});
type AuthResult = { ok: true; auth: { pass?: string; accessToken?: string } } | { ok: false; error: string };
async function resolveAuth(
userId: number,
authType: string,
email: string,
credentials: Record<string, unknown>,
): Promise<AuthResult> {
if (authType === 'oauth') {
const integrationId = credentials.userIntegrationId as number | undefined;
if (!integrationId) return { ok: false, error: 'Missing userIntegrationId for OAuth' };
let accessToken: string | null;
try {
accessToken = await getValidGoogleAccessToken(userId);
} catch (err) {
return {
ok: false,
error: `Token refresh failed — reconnect Google account: ${err instanceof Error ? err.message : err}`,
};
}
if (!accessToken) return { ok: false, error: 'No access token available — reconnect Google account' };
return { ok: true, auth: { accessToken } };
}
if (authType === 'password') {
const pass = credentials.password as string | undefined;
if (!pass) return { ok: false, error: 'Missing password' };
return { ok: true, auth: { pass } };
}
return { ok: false, error: `Unknown auth type: ${authType}` };
}
-44
View File
@@ -1,44 +0,0 @@
import { getAllSyncedAccounts, getUserById } from 'officerdb';
import { performResync } from './resync';
const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
let timer: ReturnType<typeof setInterval> | null = null;
async function tick() {
try {
const accounts = await getAllSyncedAccounts();
if (accounts.length === 0) return;
for (const account of accounts) {
const user = await getUserById(account.userId);
if (!user) continue;
try {
console.log(`[email-cron] Resyncing ${account.email}`);
const result = await performResync({ accountId: account.id, userEmail: user.email, userId: user.id });
if (result.saved > 0) {
console.log(`[email-cron] ${account.email}: ${result.saved} new emails`);
}
} catch (err) {
console.error(`[email-cron] Failed to resync ${account.email}:`, err instanceof Error ? err.message : err);
}
}
} catch (err) {
console.error('[email-cron] Error:', err instanceof Error ? err.message : err);
}
}
export function initEmailCron() {
if (timer) return;
console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`);
timer = setInterval(tick, INTERVAL_MS);
setTimeout(tick, 30_000);
}
export function stopEmailCron() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
-170
View File
@@ -1,170 +0,0 @@
import type { ImapFlow } from 'imapflow';
import { getAllSyncedAccounts, getUserById } from 'officerdb';
import { performResync } from './resync';
import { getValidGoogleAccessToken } from '../../api/integrations/google-auth';
// Real-time email via IMAP IDLE: one persistent connection per account. imapflow auto-enters IDLE
// while a mailbox is open and pushes an `exists` event the instant new mail lands — we then run the
// same incremental resync the cron uses. The cron stays on as a slow backstop for missed events.
type Account = Awaited<ReturnType<typeof getAllSyncedAccounts>>[number];
type Watcher = {
account: Account;
client: ImapFlow | null;
closing: boolean;
syncing: boolean;
pending: boolean; // an exists event arrived mid-sync → run once more
backoff: number;
reconnectTimer: ReturnType<typeof setTimeout> | null;
};
const RECONNECT_BASE = 5_000;
const RECONNECT_MAX = 5 * 60_000;
const RECONCILE_MS = 5 * 60_000;
const watchers = new Map<number, Watcher>();
let reconcileTimer: ReturnType<typeof setInterval> | null = null;
let notifyNewMail: ((userEmail: string) => void) | null = null;
async function resolveAuth(account: Account): Promise<{ user: string; pass?: string; accessToken?: string } | null> {
if (account.authType === 'oauth') {
const token = await getValidGoogleAccessToken(account.userId).catch(() => null);
return token ? { user: account.email, accessToken: token } : null;
}
const pass = (account.credentials as Record<string, unknown> | null)?.password as string | undefined;
return pass ? { user: account.email, pass } : null;
}
// Coalesced incremental sync — a new event during a sync queues exactly one follow-up.
async function runSync(w: Watcher): Promise<void> {
if (w.syncing) {
w.pending = true;
return;
}
w.syncing = true;
try {
const user = await getUserById(w.account.userId);
if (!user) return;
const result = await performResync({ accountId: w.account.id, userEmail: user.email, userId: user.id });
if (result.saved > 0) {
console.log(`[email-idle] ${w.account.email}: ${result.saved} new`);
notifyNewMail?.(user.email);
}
} catch (err) {
console.error(`[email-idle] sync failed for ${w.account.email}:`, err instanceof Error ? err.message : err);
} finally {
w.syncing = false;
if (w.pending) {
w.pending = false;
void runSync(w);
}
}
}
function scheduleReconnect(w: Watcher): void {
if (w.closing || w.reconnectTimer) return;
const delay = w.backoff;
w.backoff = Math.min(w.backoff * 2, RECONNECT_MAX);
w.reconnectTimer = setTimeout(() => {
w.reconnectTimer = null;
void connect(w);
}, delay);
}
async function connect(w: Watcher): Promise<void> {
if (w.closing) return;
const auth = await resolveAuth(w.account);
if (!auth) {
scheduleReconnect(w);
return;
}
const { ImapFlow } = await import('imapflow');
const client = new ImapFlow({
host: w.account.imapHost,
port: w.account.imapPort,
secure: w.account.imapSecure,
auth,
logger: false,
emitLogs: false,
});
w.client = client;
client.on('exists', () => void runSync(w));
client.on('error', () => {
/* a 'close' event follows and handles the reconnect */
});
client.on('close', () => {
w.client = null;
scheduleReconnect(w);
});
try {
await client.connect();
await client.mailboxOpen('INBOX'); // imapflow now auto-IDLEs and emits `exists` on new mail
w.backoff = RECONNECT_BASE; // reset after a clean connect
console.log(`[email-idle] watching ${w.account.email}`);
void runSync(w); // catch up on anything that arrived while disconnected
} catch (err) {
console.error(`[email-idle] connect failed for ${w.account.email}:`, err instanceof Error ? err.message : err);
w.client = null;
scheduleReconnect(w);
}
}
function startWatcher(account: Account): void {
if (watchers.has(account.id)) return;
const w: Watcher = {
account,
client: null,
closing: false,
syncing: false,
pending: false,
backoff: RECONNECT_BASE,
reconnectTimer: null,
};
watchers.set(account.id, w);
void connect(w);
}
function stopWatcher(id: number): void {
const w = watchers.get(id);
if (!w) return;
w.closing = true;
if (w.reconnectTimer) clearTimeout(w.reconnectTimer);
w.client?.logout().catch(() => {});
watchers.delete(id);
}
// Keep watchers in sync with the account list (added/removed/credential changes).
async function reconcile(): Promise<void> {
try {
const accounts = await getAllSyncedAccounts();
const ids = new Set(accounts.map((a) => a.id));
for (const a of accounts) {
const w = watchers.get(a.id);
if (!w) startWatcher(a);
else w.account = a; // refresh host/creds for the next reconnect
}
for (const id of [...watchers.keys()]) if (!ids.has(id)) stopWatcher(id);
} catch (err) {
console.error('[email-idle] reconcile error:', err instanceof Error ? err.message : err);
}
}
export function initEmailIdle(onNewMail?: (userEmail: string) => void): void {
if (reconcileTimer) return;
notifyNewMail = onNewMail ?? null;
console.log('[email-idle] starting IMAP IDLE watchers');
setTimeout(() => void reconcile(), 5_000);
reconcileTimer = setInterval(() => void reconcile(), RECONCILE_MS);
}
export function stopEmailIdle(): void {
if (reconcileTimer) {
clearInterval(reconcileTimer);
reconcileTimer = null;
}
for (const id of [...watchers.keys()]) stopWatcher(id);
}
-715
View File
@@ -1,715 +0,0 @@
import type { Database } from 'bun:sqlite';
import { createHash } from 'node:crypto';
import { openEmailDb, upsertFromRawEml, setSyncMeta, getSyncMeta } from './store';
import {
getUserByEmail,
getUserIntegration,
upsertUserIntegration,
getServerIntegration,
getDockPaths,
setDockPaths,
updateEmailAccountStatus,
getEmailAccount,
} from 'officerdb';
import { refreshGoogleAccessToken } from '@@/api/integrations/google-auth';
import type { SyncStep, SyncCtx } from './sync-runner';
// Thrown by the handlers for failures that a retry cannot fix (missing credentials, deleted account).
// The queue used it to skip retrying; here it is an ordinary error whose message reaches the sync state.
export class PermanentError extends Error {}
// ── Credentials ──
export type GmailCredentials = {
email: string;
userId: number;
appPassword?: string;
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
};
export async function loadGmailCredentials(userEmail: string): Promise<GmailCredentials> {
const dbUser = await getUserByEmail(userEmail);
if (!dbUser) throw new PermanentError('User not found');
const userGoogle = await getUserIntegration(dbUser.id, 'google');
const config = userGoogle?.config as Record<string, unknown> | undefined;
const gmailEmail = (config?.email as string) ?? userEmail;
// Prefer OAuth tokens, fall back to app password
if (config?.accessToken && config?.refreshToken) {
return {
email: gmailEmail,
userId: dbUser.id,
accessToken: config.accessToken as string,
refreshToken: config.refreshToken as string,
expiresAt: config.expiresAt as number | undefined,
appPassword: config.imapAppPassword as string | undefined,
};
}
if (!config?.imapAppPassword) {
throw new PermanentError('Gmail App Password not configured — set it in Settings → Integrations');
}
return { email: gmailEmail, userId: dbUser.id, appPassword: config.imapAppPassword as string };
}
// ── Stable ID from Message-Id header ──
function messageIdToStableId(raw: string): string | null {
const match = raw.match(/^Message-Id:\s*<?([^>\s]+)>?/im);
if (!match?.[1]) return null;
return createHash('sha1').update(match[1]).digest('hex').slice(0, 16);
}
// ── Gmail API label → our label mapping ──
const GMAIL_API_LABEL_MAP: Record<string, string> = {
INBOX: 'inbox',
SENT: 'sent',
DRAFT: 'draft',
STARRED: 'starred',
IMPORTANT: 'important',
TRASH: 'trash',
SPAM: 'spam',
CATEGORY_PROMOTIONS: 'promotions',
CATEGORY_SOCIAL: 'social',
CATEGORY_UPDATES: 'updates',
CATEGORY_FORUMS: 'forums',
};
const SKIP_LABELS = new Set(['TRASH', 'SPAM', 'DRAFT']);
function gmailApiLabelsToLabels(labelIds: string[]): string[] {
const labels: string[] = [];
for (const id of labelIds) {
const mapped = GMAIL_API_LABEL_MAP[id];
if (mapped) {
labels.push(mapped);
} else if (!id.startsWith('CATEGORY_') && id !== 'UNREAD' && id !== 'IMPORTANT') {
// Custom label — use as-is (Gmail API returns label IDs, not names, for custom labels)
labels.push(id.toLowerCase());
}
}
return labels;
}
function shouldSkipMessage(labelIds: string[]): boolean {
return labelIds.some((id) => SKIP_LABELS.has(id));
}
// ── Gmail API helpers ──
const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me';
export type GmailApiResult = { saved: number; skipped: number; errors: number };
async function gmailApiFetch(accessToken: string, path: string, params?: Record<string, string>): Promise<Response> {
const url = new URL(`${GMAIL_API}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
return res;
}
async function getGmailProfile(accessToken: string): Promise<{ historyId: string }> {
const res = await gmailApiFetch(accessToken, '/profile');
if (!res.ok) throw new Error(`Gmail profile request failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as { historyId: string };
return data;
}
type HistoryMessage = { id: string; threadId: string };
type HistoryEntry = { messagesAdded?: Array<{ message: HistoryMessage }> };
type HistoryResponse = { history?: HistoryEntry[]; historyId: string; nextPageToken?: string };
async function getHistoryChanges(
accessToken: string,
startHistoryId: string,
): Promise<{ messageIds: string[]; historyId: string }> {
const messageIds = new Set<string>();
let pageToken: string | undefined;
let latestHistoryId = startHistoryId;
while (true) {
const params: Record<string, string> = {
startHistoryId,
historyTypes: 'messageAdded',
maxResults: '500',
};
if (pageToken) params.pageToken = pageToken;
const res = await gmailApiFetch(accessToken, '/history', params);
if (res.status === 404 || res.status === 410) {
// historyId too old or invalid — caller should fall back to date-based listing
throw new HistoryExpiredError();
}
if (!res.ok) throw new Error(`Gmail history request failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as HistoryResponse;
latestHistoryId = data.historyId;
if (data.history) {
for (const entry of data.history) {
if (entry.messagesAdded) {
for (const added of entry.messagesAdded) {
messageIds.add(added.message.id);
}
}
}
}
if (!data.nextPageToken) break;
pageToken = data.nextPageToken;
}
return { messageIds: [...messageIds], historyId: latestHistoryId };
}
class HistoryExpiredError extends Error {
constructor() {
super('Gmail historyId expired');
}
}
type MessageListResponse = { messages?: Array<{ id: string }>; nextPageToken?: string };
async function listMessagesSince(accessToken: string, sinceDate: string): Promise<string[]> {
const messageIds: string[] = [];
let pageToken: string | undefined;
while (true) {
const params: Record<string, string> = {
q: `after:${sinceDate}`,
maxResults: '500',
};
if (pageToken) params.pageToken = pageToken;
const res = await gmailApiFetch(accessToken, '/messages', params);
if (!res.ok) throw new Error(`Gmail messages.list failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as MessageListResponse;
if (data.messages) {
for (const msg of data.messages) messageIds.push(msg.id);
}
if (!data.nextPageToken) break;
pageToken = data.nextPageToken;
}
return messageIds;
}
type GmailMessage = { id: string; labelIds: string[]; raw: string; historyId: string };
async function fetchRawMessage(accessToken: string, messageId: string): Promise<GmailMessage | null> {
const res = await gmailApiFetch(accessToken, `/messages/${messageId}`, { format: 'raw' });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Gmail messages.get failed: ${res.status} ${await res.text()}`);
return (await res.json()) as GmailMessage;
}
// ── Gmail API sync (for resyncs) ──
export type GmailApiSyncParams = {
creds: GmailCredentials;
db: Database;
onProgress?: (saved: number, total: number) => void;
};
export async function gmailApiSync({ creds, db, onProgress }: GmailApiSyncParams): Promise<GmailApiResult> {
if (!creds.accessToken) throw new PermanentError('OAuth access token required for Gmail API sync');
let saved = 0;
let skipped = 0;
let errors = 0;
// Load existing IDs for dedup
const existingIds = new Set<string>();
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
for (const row of rows) existingIds.add(row.id);
const storedHistoryId = getSyncMeta(db, 'gmail_history_id');
let messageIds: string[];
let newHistoryId: string;
if (storedHistoryId) {
// Try history-based sync first
try {
const result = await getHistoryChanges(creds.accessToken, storedHistoryId);
messageIds = result.messageIds;
newHistoryId = result.historyId;
console.log(`[gmail-sync] History returned ${messageIds.length} new message(s)`);
} catch (err) {
if (err instanceof HistoryExpiredError) {
// historyId too old — fall back to date-based listing
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
const sinceDate = lastSyncDate ?? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!;
console.log(`[gmail-sync] History expired, falling back to messages since ${sinceDate}`);
messageIds = await listMessagesSince(creds.accessToken, sinceDate);
const profile = await getGmailProfile(creds.accessToken);
newHistoryId = profile.historyId;
console.log(`[gmail-sync] Date query returned ${messageIds.length} message(s)`);
} else {
throw err;
}
}
} else {
// No historyId stored — use date-based listing
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
const sinceDate = lastSyncDate ?? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!;
console.log(`[gmail-sync] No historyId, listing messages since ${sinceDate}`);
messageIds = await listMessagesSince(creds.accessToken, sinceDate);
const profile = await getGmailProfile(creds.accessToken);
newHistoryId = profile.historyId;
console.log(`[gmail-sync] Date query returned ${messageIds.length} message(s)`);
}
// Fetch and store each new message
for (let i = 0; i < messageIds.length; i++) {
const gmailId = messageIds[i]!;
try {
const msg = await fetchRawMessage(creds.accessToken, gmailId);
if (!msg) {
skipped++;
continue;
}
if (shouldSkipMessage(msg.labelIds ?? [])) {
skipped++;
continue;
}
// Gmail API returns base64url-encoded raw RFC822
const rawBytes = Buffer.from(msg.raw, 'base64url');
const raw = rawBytes.toString('utf-8');
const id = messageIdToStableId(raw);
if (!id) {
errors++;
continue;
}
if (existingIds.has(id)) {
skipped++;
continue;
}
const labels = gmailApiLabelsToLabels(msg.labelIds ?? []);
upsertFromRawEml({ db, id, raw, integration: 'gmail', emailAccount: creds.email, labels });
existingIds.add(id);
saved++;
if ((saved + skipped) % 50 === 0) {
onProgress?.(saved, messageIds.length);
}
} catch (err) {
console.log(`[gmail-sync] Error fetching message ${gmailId}: ${err instanceof Error ? err.message : err}`);
errors++;
}
}
// Persist new historyId
setSyncMeta(db, 'gmail_history_id', newHistoryId);
return { saved, skipped, errors };
}
// ── IMAP sync (for first sync) ──
// Folder → label mapping for IMAP
const GMAIL_PREFIX_RE = /^\[(?:Gmail|Google Mail)\]\//;
function normalizeGmailFolder(folder: string): string {
return folder.replace(GMAIL_PREFIX_RE, '');
}
const SUFFIX_LABEL_MAP: Record<string, string> = {
'Sent Mail': 'sent',
Drafts: 'draft',
Starred: 'starred',
Important: 'important',
'All Mail': 'archive',
Trash: 'trash',
Bin: 'trash',
Spam: 'spam',
};
function folderToLabel(folder: string): string | null {
const suffix = normalizeGmailFolder(folder);
if (suffix !== folder) {
if (SUFFIX_LABEL_MAP[suffix]) return SUFFIX_LABEL_MAP[suffix]!;
return suffix.toLowerCase();
}
if (folder === 'INBOX') return 'inbox';
return folder.toLowerCase();
}
const IMAP_LABEL_MAP: Record<string, string> = {
'\\Inbox': 'inbox',
'\\Sent': 'sent',
'\\Drafts': 'draft',
'\\Starred': 'starred',
'\\Important': 'important',
'\\All': 'archive',
'\\Trash': 'trash',
'\\Junk': 'spam',
};
function imapLabelsToLabels(gmailLabels: Set<string>): string[] {
const labels: string[] = [];
for (const gl of gmailLabels) {
const mapped = IMAP_LABEL_MAP[gl];
if (mapped) {
labels.push(mapped);
} else if (!gl.startsWith('\\')) {
labels.push(gl.toLowerCase());
}
}
if (labels.length > 1 && labels.includes('archive')) {
return labels.filter((l) => l !== 'archive');
}
return labels;
}
const SKIP_SPECIAL_USE = new Set(['\\Trash', '\\Junk', '\\All']);
function shouldSkipFolder(folder: { specialUse?: string; path: string; flags: Set<string> }): boolean {
if (folder.flags.has('\\Noselect') || folder.flags.has('\\NonExistent')) return true;
if (folder.specialUse && SKIP_SPECIAL_USE.has(folder.specialUse)) return true;
const norm = normalizeGmailFolder(folder.path);
return norm === 'All Mail' || norm === 'Trash' || norm === 'Spam' || norm === 'Bin';
}
type ImapSyncParams = {
creds: GmailCredentials;
db: Database;
onProgress?: (fetched: number, folder: string) => void;
};
type SyncResult = { saved: number; skipped: number; errors: number };
async function imapFullSync({ creds, db, onProgress }: ImapSyncParams): Promise<SyncResult> {
const { ImapFlow } = await import('imapflow');
const auth: { user: string; pass?: string; accessToken?: string } = { user: creds.email };
if (creds.appPassword) {
auth.pass = creds.appPassword;
} else if (creds.accessToken) {
auth.accessToken = creds.accessToken;
} else {
throw new PermanentError('No authentication method available for IMAP');
}
const client = new ImapFlow({
host: 'imap.gmail.com',
port: 993,
secure: true,
auth,
logger: false,
});
let saved = 0;
let skipped = 0;
let errors = 0;
try {
await client.connect();
console.log('[gmail-sync] IMAP connected for full sync');
const folders = await client.list({ statusQuery: { uidNext: true, uidValidity: true } });
const existingIds = new Set<string>();
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
for (const row of rows) existingIds.add(row.id);
const foldersToSync: typeof folders = [];
for (const folder of folders) {
if (shouldSkipFolder(folder)) continue;
const folderPath = folder.path;
const uidValidityKey = `imap_uidvalidity:${folderPath}`;
const lastUidKey = `imap_lastuid:${folderPath}`;
const storedUidValidity = getSyncMeta(db, uidValidityKey);
const storedLastUid = getSyncMeta(db, lastUidKey);
const uidValidity = folder.status?.uidValidity ? String(folder.status.uidValidity) : null;
const uidNext = folder.status?.uidNext ?? 0;
const lastUid =
storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0;
if (uidValidity) setSyncMeta(db, uidValidityKey, uidValidity);
if (lastUid > 0 && uidNext <= lastUid + 1) continue;
foldersToSync.push(folder);
}
console.log(`[gmail-sync] ${foldersToSync.length} folder(s) to sync`);
for (const folder of foldersToSync) {
const folderPath = folder.path;
const uidValidityKey = `imap_uidvalidity:${folderPath}`;
const lastUidKey = `imap_lastuid:${folderPath}`;
const storedUidValidity = getSyncMeta(db, uidValidityKey);
const storedLastUid = getSyncMeta(db, lastUidKey);
let lock;
try {
lock = await client.getMailboxLock(folderPath);
} catch (err) {
console.log(`[gmail-sync] Skipping folder ${folderPath}: ${err instanceof Error ? err.message : err}`);
continue;
}
try {
const mailbox = client.mailbox;
if (!mailbox) continue;
const uidValidity = String(mailbox.uidValidity);
let lastUid = 0;
if (storedUidValidity === uidValidity && storedLastUid) {
lastUid = parseInt(storedLastUid, 10);
}
const range = lastUid > 0 ? `${lastUid + 1}:*` : '1:*';
let maxUid = lastUid;
let folderFetched = 0;
try {
for await (const msg of client.fetch(range, { source: true, labels: true, uid: true }, { uid: true })) {
if (msg.uid <= lastUid) continue;
maxUid = Math.max(maxUid, msg.uid);
folderFetched++;
if (!msg.source) {
errors++;
continue;
}
const raw = msg.source.toString('utf-8');
const id = messageIdToStableId(raw);
if (!id) {
errors++;
continue;
}
if (existingIds.has(id)) {
skipped++;
continue;
}
const folderLabel = folderToLabel(folderPath);
const gmailLabels = msg.labels ? imapLabelsToLabels(msg.labels) : [];
const labels = folderLabel ? [...new Set([folderLabel, ...gmailLabels])] : gmailLabels;
try {
upsertFromRawEml({ db, id, raw, integration: 'gmail', emailAccount: creds.email, labels });
existingIds.add(id);
saved++;
} catch {
errors++;
}
}
} catch (fetchErr) {
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
if (!msg.includes('Nothing to fetch')) {
console.log(`[gmail-sync] Fetch error in ${folderPath}: ${msg}`);
errors++;
}
}
if (maxUid > lastUid) {
setSyncMeta(db, lastUidKey, String(maxUid));
}
setSyncMeta(db, uidValidityKey, uidValidity);
if (folderFetched > 0) {
console.log(`[gmail-sync] ${folderPath}: fetched ${folderFetched}, saved ${saved}, skipped ${skipped}`);
onProgress?.(saved + skipped, folderPath);
}
} finally {
lock.release();
}
}
} finally {
await client.logout().catch(() => {});
}
return { saved, skipped, errors };
}
// ── Handler ──
const gmailSyncHandler = {
type: 'gmail-sync',
retry: { delayMs: 15 * 60 * 1000, maxRetries: 10 },
steps: [
{
name: 'Verify credentials',
run: async (ctx: SyncCtx) => {
const creds = await loadGmailCredentials(ctx.job.userId);
const emailAccountId = (ctx.meta as Record<string, unknown>).emailAccountId as number | undefined;
const syncAccount = emailAccountId ? await getEmailAccount(emailAccountId) : undefined;
// Determine sync mode — check SQLite first, fall back to PostgreSQL syncMeta
const db = openEmailDb(ctx.job.userId, syncAccount?.email ?? ctx.job.userId);
let lastSyncAt: string | null = null;
try {
lastSyncAt = getSyncMeta(db, 'last_sync_at');
if (!lastSyncAt && emailAccountId) {
const pgAccount = await getEmailAccount(emailAccountId);
const pgMeta = pgAccount?.syncMeta as Record<string, string> | null;
if (pgMeta?.last_sync_at) {
lastSyncAt = pgMeta.last_sync_at;
// Backfill SQLite so future checks don't need PostgreSQL
setSyncMeta(db, 'last_sync_at', lastSyncAt);
if (pgMeta.last_sync_date) setSyncMeta(db, 'last_sync_date', pgMeta.last_sync_date);
}
}
ctx.meta.isFirstSync = !lastSyncAt;
} finally {
db.close();
}
if (emailAccountId) {
await updateEmailAccountStatus(emailAccountId, 'syncing');
ctx.meta.emailAccountId = emailAccountId;
}
// For API resyncs, refresh OAuth token if expired
if (!ctx.meta.isFirstSync && creds.accessToken && creds.refreshToken) {
const tokenExpired = !creds.expiresAt || creds.expiresAt < Date.now() + 60_000;
if (tokenExpired) {
console.log('[gmail-sync] Refreshing OAuth token');
try {
const refreshed = await refreshGoogleAccessToken(creds.refreshToken);
creds.accessToken = refreshed.accessToken;
creds.expiresAt = refreshed.expiresAt;
const userGoogle = await getUserIntegration(creds.userId, 'google');
const existingConfig = (userGoogle?.config as Record<string, unknown>) ?? {};
const serverGoogle = await getServerIntegration('google');
await upsertUserIntegration({
userId: creds.userId,
provider: 'google',
serverIntegrationId: serverGoogle?.id,
config: { ...existingConfig, accessToken: refreshed.accessToken, expiresAt: refreshed.expiresAt },
});
} catch (err) {
throw new PermanentError(`OAuth refresh failed — reconnect Google in Settings: ${err}`);
}
}
}
if (!ctx.meta.isFirstSync && !creds.accessToken) {
throw new PermanentError('OAuth not configured — connect Google in Settings → Integrations for resyncs');
}
ctx.meta.creds = creds;
console.log(
`[gmail-sync] ${ctx.meta.isFirstSync ? 'First sync (IMAP)' : 'Resync (Gmail API)'} — ${creds.accessToken ? 'OAuth' : 'App Password'}`,
);
},
},
{
name: 'Sync emails',
run: async (ctx: SyncCtx) => {
const creds = ctx.meta.creds as GmailCredentials;
const emailAccountId = (ctx.meta as Record<string, unknown>).emailAccountId as number | undefined;
const syncAccount = emailAccountId ? await getEmailAccount(emailAccountId) : undefined;
const db = openEmailDb(ctx.job.userId, syncAccount?.email ?? ctx.job.userId);
try {
let result: SyncResult;
if (ctx.meta.isFirstSync) {
// First sync: IMAP with app password
await ctx.updateProgress({ current: 0, total: 0, label: 'Connecting to Gmail (IMAP)...' });
result = await imapFullSync({
creds,
db,
onProgress: (fetched, folder) => {
ctx.updateProgress({ current: fetched, total: 0, label: `Syncing ${folder}...` });
},
});
// Seed historyId for future API syncs
if (creds.accessToken) {
try {
const profile = await getGmailProfile(creds.accessToken);
setSyncMeta(db, 'gmail_history_id', profile.historyId);
console.log(`[gmail-sync] Seeded historyId: ${profile.historyId}`);
} catch (err) {
console.log(`[gmail-sync] Could not seed historyId: ${err}`);
}
}
} else {
// Resync: Gmail API with OAuth
await ctx.updateProgress({ current: 0, total: 0, label: 'Checking for new emails...' });
result = await gmailApiSync({
creds,
db,
onProgress: (saved, total) => {
ctx.updateProgress({ current: saved, total, label: `Fetching new emails (${saved}/${total})...` });
},
});
}
console.log(`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`);
setSyncMeta(db, 'last_sync_date', new Date().toISOString().split('T')[0]!);
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
ctx.meta.syncResult = result;
await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${result.saved} new emails` });
} finally {
db.close();
}
},
},
{
name: 'Finalize',
run: async (ctx: SyncCtx) => {
const result = ctx.meta.syncResult as SyncResult | undefined;
const emailAccountId = ctx.meta.emailAccountId as number | undefined;
if (emailAccountId) {
await updateEmailAccountStatus(emailAccountId, 'synced');
}
if (result && result.saved > 0) {
try {
const dbUser = await getUserByEmail(ctx.job.userId);
if (dbUser) {
const paths = await getDockPaths(dbUser.id);
if (!paths) {
const defaults = ['/', '/files', '/automation', '/dashboards', '/chat'];
await setDockPaths(dbUser.id, [...defaults, '/email']);
} else if (!paths.includes('/email')) {
await setDockPaths(dbUser.id, [...paths, '/email']);
}
}
} catch {
// Non-fatal
}
}
},
},
],
};
/** The handler's steps, for the sidecar's own runner. */
export const gmailSyncSteps: SyncStep[] = gmailSyncHandler.steps as SyncStep[];
-78
View File
@@ -1,78 +0,0 @@
import { Hono } from 'hono';
import { getUserById } from 'officerdb';
import { CustomError } from '../../custom-errors';
import { bodyParser } from '../../_middlewares/body-parser';
import { emailRouter } from './routes';
// The email sidecar's own listener. `/api/email/*` on the platform is a proxy onto this — the platform
// authenticates the owner, injects X-Officer-User, and forwards without reading the body.
//
// The routes moved here verbatim, Hono and all, so this file's only real job is to reconstruct the three
// things the platform's middleware used to provide: the authenticated user on the context, the parsed
// body on the context, and an error handler that turns thrown custom-errors into status codes.
type HonoVariables = { user: { id: number }; body: Record<string, unknown>; origin: string };
const app = new Hono<{ Variables: HonoVariables }>();
// The platform's userMiddleware set the whole user row on the context from the JWT. The proxy injects only
// the id (X-Officer-User), so the rest is loaded here — and it has to be the WHOLE row, not just the id:
// the mail store is keyed by the owner's email (DATA_PATH/<owner>/email_accounts/<account>/emails.db), so
// a user object without `email` silently resolves to the wrong path and the mailbox reads as empty.
//
// Cached because this runs on every request and the owner does not change; single-user is a hard invariant.
let cachedUser: { id: number; email: string } | null = null;
app.use('*', async (ctx, next) => {
const id = Number(ctx.req.header('X-Officer-User'));
if (!Number.isFinite(id) || id <= 0) return ctx.json({ error: 'missing X-Officer-User' }, 401);
if (!cachedUser || cachedUser.id !== id) {
const row = await getUserById(id);
if (!row?.email) return ctx.json({ error: `unknown user ${id}` }, 401);
cachedUser = row as { id: number; email: string };
}
ctx.set('user', cachedUser as never);
await next();
});
// Handlers read the body off the context (`ctx.get('body')`), which only the platform's middleware used to
// provide. Without this every write route here destructured undefined — `POST /accounts` threw on
// `body.provider` and surfaced as a bare "failed to add account" toast. `isProxiedPath` is inert in this
// process (the prefix set is populated by `createSidecarProxy`, which runs in the platform), so nothing is
// skipped; multipart on `/send` is parsed once and served from Hono's cache when the route asks again.
app.use('*', bodyParser());
app.route('/', emailRouter as never);
// Mirrors the platform's own handler (`hono.ts` onError) rather than inventing a second error shape. Two
// bugs lived in the version that didn't: custom-errors carry `statusCode`, NOT `status`, so every
// deliberate 4xx fell through to the 500 branch with its message replaced by "internal error"; and the
// body was JSON where the whole API answers errors as plain text, which `useClient` reads with
// `res.text()` — so even a correct message reached the client wrapped in `{"error":…}`.
app.onError((err, ctx) => {
if (err instanceof CustomError) {
if (err.returnValue) {
return typeof err.returnValue === 'string'
? ctx.text(err.returnValue, err.statusCode)
: ctx.json(err.returnValue, err.statusCode);
}
return ctx.text(err.message, err.statusCode);
}
console.error('[email] unhandled error', err);
return ctx.text('Internal Server Error', 500);
});
export function startEmailServer(): number {
const server = Bun.serve({
port: 0,
hostname: '127.0.0.1',
// A from-scratch mailbox sync answers slowly; the platform proxy extends its own side to match.
idleTimeout: 255,
fetch: app.fetch,
});
const port = server.port;
if (port == null) throw new Error('[email] failed to acquire a port');
console.log(`[email] http server listening on http://127.0.0.1:${port}`);
return port;
}
-434
View File
@@ -1,434 +0,0 @@
import { createHash } from 'node:crypto';
import { openEmailDb, upsertFromRawEml } from './store';
import { readSyncMeta, writeSyncMeta } from './sync-meta';
import { refreshGoogleAccessToken } from '../../api/integrations/google-auth';
import {
getEmailAccount,
getUserIntegration,
upsertUserIntegration,
getServerIntegration,
updateEmailAccountStatus,
getDockPaths,
setDockPaths,
} from 'officerdb';
import type { SyncStep, SyncCtx } from './sync-runner';
// Thrown by the handlers for failures that a retry cannot fix (missing credentials, deleted account).
// The queue used it to skip retrying; here it is an ordinary error whose message reaches the sync state.
export class PermanentError extends Error {}
// ── Types for job meta (passed by the API server at enqueue time) ──
type EmailSyncMeta = {
emailAccountId: number;
userEmail: string;
account: {
id: number;
userId: number;
email: string;
imapHost: string;
imapPort: number;
imapSecure: boolean;
provider: string;
authType: string;
credentials: Record<string, unknown>;
};
imapAuth: { user: string; pass?: string; accessToken?: string };
saved?: number;
};
// ── Stable ID from Message-Id header ──
function messageIdToStableId(raw: string): string | null {
const match = raw.match(/^Message-Id:\s*<?([^>\s]+)>?/im);
if (!match?.[1]) return null;
return createHash('sha1').update(match[1]).digest('hex').slice(0, 16);
}
// ── IMAP folder → label mapping ──
const SPECIAL_USE_LABEL_MAP: Record<string, string> = {
'\\Inbox': 'inbox',
'\\Sent': 'sent',
'\\Drafts': 'draft',
'\\Flagged': 'starred',
'\\Trash': 'trash',
'\\Junk': 'spam',
'\\All': 'archive',
};
const SKIP_SPECIAL_USE = new Set(['\\Trash', '\\Junk', '\\All']);
type FolderInfo = {
specialUse?: string;
path: string;
flags: Set<string>;
status?: { uidNext?: number; uidValidity?: number };
};
function shouldSkipFolder(folder: FolderInfo): boolean {
if (folder.flags.has('\\Noselect') || folder.flags.has('\\NonExistent')) return true;
if (folder.specialUse && SKIP_SPECIAL_USE.has(folder.specialUse)) return true;
return false;
}
function folderToLabel(folder: FolderInfo): string {
if (folder.specialUse && SPECIAL_USE_LABEL_MAP[folder.specialUse]) {
return SPECIAL_USE_LABEL_MAP[folder.specialUse]!;
}
if (folder.path === 'INBOX') return 'inbox';
return folder.path.toLowerCase();
}
// ── OAuth token refresh ──
async function resolveImapAuth(meta: EmailSyncMeta): Promise<{ user: string; pass?: string; accessToken?: string }> {
if (meta.account.authType !== 'oauth') return meta.imapAuth;
// Refresh OAuth token if expired
const userGoogle = await getUserIntegration(meta.account.userId, 'google');
const config = userGoogle?.config as Record<string, unknown> | undefined;
if (!config?.refreshToken) {
throw new PermanentError('Google OAuth not configured — reconnect your Google account');
}
const expiresAt = config.expiresAt as number | undefined;
const tokenExpired = !expiresAt || expiresAt < Date.now() + 60_000;
if (!tokenExpired && config.accessToken) {
return { user: meta.account.email, accessToken: config.accessToken as string };
}
console.log('[email-sync] Refreshing OAuth token');
const refreshed = await refreshGoogleAccessToken(config.refreshToken as string);
const serverGoogle = await getServerIntegration('google');
await upsertUserIntegration({
userId: meta.account.userId,
provider: 'google',
serverIntegrationId: serverGoogle?.id,
config: { ...config, accessToken: refreshed.accessToken, expiresAt: refreshed.expiresAt },
});
return { user: meta.account.email, accessToken: refreshed.accessToken };
}
// ── Handler ──
const emailSyncHandler = {
type: 'email-sync',
retry: { delayMs: 15 * 60 * 1000, maxRetries: 5 },
steps: [
{
name: 'Sync emails',
run: async (ctx: SyncCtx) => {
const { ImapFlow } = await import('imapflow');
const meta = ctx.meta as unknown as EmailSyncMeta;
const { account, userEmail } = meta;
// Resolve auth (refreshes OAuth token if needed)
const imapAuth = await resolveImapAuth(meta);
// Always re-read the position rather than trusting job meta, which can be stale after a retry.
const freshAccount = await getEmailAccount(account.id);
if (!freshAccount) throw new PermanentError(`Email account ${account.id} not found`);
// Opened here rather than further down because the position now lives inside it. Postgres is
// passed only as the one-time backfill for accounts that predate the move — see sync-meta.ts.
const db = openEmailDb(userEmail, account.email);
const syncMeta: Record<string, unknown> = readSyncMeta(db, freshAccount.syncMeta as Record<string, unknown>);
const isIncremental = !!syncMeta.last_sync_at;
await updateEmailAccountStatus(account.id, 'syncing');
console.log(`[email-sync] ${isIncremental ? 'Incremental' : 'Initial'} sync for ${account.email}`);
const MAX_RECONNECTS = 10;
const RECONNECT_DELAY_MS = 5_000;
let reconnects = 0;
let saved = 0;
let skipped = 0;
let errors = 0;
let allDone = false;
// Load existing IDs for dedup (once, shared across reconnections)
const existingIds = new Set<string>();
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
for (const row of rows) existingIds.add(row.id);
try {
while (!allDone && reconnects <= MAX_RECONNECTS) {
if (reconnects > 0) {
console.log(
`[email-sync] Reconnecting (${reconnects}/${MAX_RECONNECTS}) after ${RECONNECT_DELAY_MS / 1000}s...`,
);
await ctx.updateProgress({
current: saved + skipped,
total: 0,
label: `Reconnecting (${reconnects}/${MAX_RECONNECTS})...`,
});
await new Promise((r) => setTimeout(r, RECONNECT_DELAY_MS));
// Re-read syncMeta from DB to get latest saved UIDs
const updated = await getEmailAccount(account.id);
if (updated?.syncMeta) {
Object.assign(syncMeta, updated.syncMeta as Record<string, unknown>);
}
} else {
await ctx.updateProgress({ current: 0, total: 0, label: 'Connecting...' });
}
const client = new ImapFlow({
host: account.imapHost,
port: account.imapPort,
secure: account.imapSecure,
auth: imapAuth,
logger: false,
socketTimeout: 30 * 60 * 1000,
});
const connState = { error: null as Error | null };
client.on('error', (err: Error) => {
console.log(`[email-sync] IMAP connection error: ${err.message}`);
connState.error = err;
});
try {
await client.connect();
console.log(`[email-sync] IMAP connected${reconnects > 0 ? ` (reconnect ${reconnects})` : ''}`);
const folders = (await client.list({
statusQuery: { uidNext: true, uidValidity: true },
})) as FolderInfo[];
// Filter to folders that still need syncing
const foldersToSync: Array<{ folder: FolderInfo; lastUid: number }> = [];
for (const folder of folders) {
if (shouldSkipFolder(folder)) continue;
const uidValidityKey = `imap_uidvalidity:${folder.path}`;
const lastUidKey = `imap_lastuid:${folder.path}`;
const storedUidValidity = syncMeta[uidValidityKey] as string | undefined;
const storedLastUid = syncMeta[lastUidKey] as string | undefined;
const uidValidity = folder.status?.uidValidity ? String(folder.status.uidValidity) : null;
const uidNext = folder.status?.uidNext ?? 0;
let lastUid =
storedUidValidity && uidValidity === storedUidValidity && storedLastUid
? parseInt(storedLastUid, 10)
: 0;
if (storedUidValidity && uidValidity && storedUidValidity !== uidValidity) {
console.log(`[email-sync] UIDVALIDITY changed for ${folder.path} — will re-scan`);
lastUid = 0;
}
if (lastUid > 0 && uidNext <= lastUid + 1) continue;
foldersToSync.push({ folder, lastUid });
}
if (foldersToSync.length === 0) {
console.log('[email-sync] All folders synced');
allDone = true;
await client.logout().catch(() => {});
break;
}
console.log(`[email-sync] ${foldersToSync.length} folder(s) to sync`);
let connectionLost = false;
for (let fi = 0; fi < foldersToSync.length; fi++) {
const { folder, lastUid } = foldersToSync[fi]!;
if (connState.error) {
connectionLost = true;
break;
}
console.log(`[email-sync] Syncing folder ${fi + 1}/${foldersToSync.length}: ${folder.path}`);
let lock;
try {
lock = await client.getMailboxLock(folder.path);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes('not available') || errMsg.includes('timeout') || errMsg.includes('closed')) {
console.log(`[email-sync] Connection lost at folder ${folder.path}`);
connectionLost = true;
break;
}
console.log(`[email-sync] Skipping folder ${folder.path}: ${errMsg}`);
continue;
}
try {
const mailbox = client.mailbox;
if (!mailbox) continue;
const uidValidity = String(mailbox.uidValidity);
const effectiveLastUid = syncMeta[`imap_uidvalidity:${folder.path}`] === uidValidity ? lastUid : 0;
let maxUid = effectiveLastUid;
const label = folderToLabel(folder);
const range = effectiveLastUid > 0 ? `${effectiveLastUid + 1}:*` : '1:*';
try {
const fetchOpts: Record<string, unknown> = { source: true, uid: true };
if (account.provider === 'gmail') fetchOpts.labels = true;
for await (const msg of client.fetch(range, fetchOpts, { uid: true })) {
if (msg.uid <= effectiveLastUid) continue;
maxUid = Math.max(maxUid, msg.uid);
if (!msg.source) {
errors++;
continue;
}
const raw = msg.source.toString('utf-8');
const id = messageIdToStableId(raw);
if (!id) {
errors++;
continue;
}
if (existingIds.has(id)) {
skipped++;
continue;
}
const labels = [label];
try {
upsertFromRawEml({
db,
id,
raw,
integration: account.provider,
emailAccount: account.email,
labels,
});
existingIds.add(id);
saved++;
} catch {
errors++;
}
if ((saved + skipped) % 50 === 0) {
const progressLabel = `${folder.path} — ${saved.toLocaleString()} saved, ${skipped.toLocaleString()} skipped`;
console.log(`[email-sync] ${progressLabel}`);
await ctx.updateProgress({ current: saved + skipped, total: 0, label: progressLabel });
if (maxUid > effectiveLastUid) {
syncMeta[`imap_lastuid:${folder.path}`] = String(maxUid);
writeSyncMeta(db, syncMeta);
}
}
}
} catch (fetchErr) {
const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
if (errMsg.includes('Nothing to fetch')) {
// No messages in range — normal
} else if (
errMsg.includes('not available') ||
errMsg.includes('timeout') ||
errMsg.includes('closed')
) {
console.log(`[email-sync] Connection lost during fetch in ${folder.path}: ${errMsg}`);
connectionLost = true;
} else {
console.log(`[email-sync] Fetch error in ${folder.path}: ${errMsg}`);
errors++;
}
}
syncMeta[`imap_uidvalidity:${folder.path}`] = uidValidity;
if (maxUid > effectiveLastUid) {
syncMeta[`imap_lastuid:${folder.path}`] = String(maxUid);
}
console.log(`[email-sync] ${folder.path} done: ${saved} saved, ${skipped} skipped, ${errors} errors`);
await ctx.updateProgress({
current: saved + skipped,
total: 0,
label: `${folder.path}: ${saved} saved`,
});
writeSyncMeta(db, syncMeta);
} finally {
lock.release();
}
if (connectionLost) break;
}
await client.logout().catch(() => {});
if (connectionLost) {
console.log(`[email-sync] Connection lost after saving ${saved} emails — will reconnect`);
reconnects++;
continue;
}
allDone = true;
} catch (err) {
await client.logout().catch(() => {});
if (reconnects < MAX_RECONNECTS) {
console.log(`[email-sync] Error: ${err instanceof Error ? err.message : String(err)} — will reconnect`);
reconnects++;
continue;
}
throw err;
}
}
if (!allDone) {
throw new Error(`IMAP sync incomplete after ${MAX_RECONNECTS} reconnection attempts`);
}
// Mark sync complete. Last, and only after every message above is stored: if this never runs,
// the position stays behind and those messages are fetched again. Re-fetching is wasteful;
// advancing past mail that was never stored would lose it silently.
syncMeta.last_sync_at = new Date().toISOString();
writeSyncMeta(db, syncMeta);
} finally {
db.close();
}
console.log(
`[email-sync] Done: saved ${saved}, skipped ${skipped}, errors ${errors}, reconnects ${reconnects}`,
);
ctx.meta.saved = saved;
await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${saved} new emails` });
},
},
{
name: 'Finalize',
run: async (ctx: SyncCtx) => {
const meta = ctx.meta as unknown as EmailSyncMeta;
const saved = meta.saved ?? 0;
await updateEmailAccountStatus(meta.account.id, 'synced');
if (saved > 0) {
try {
const paths = await getDockPaths(meta.account.userId);
if (!paths) {
const defaults = ['/', '/files', '/automation', '/dashboards', '/chat'];
await setDockPaths(meta.account.userId, [...defaults, '/email']);
} else if (!paths.includes('/email')) {
await setDockPaths(meta.account.userId, [...paths, '/email']);
}
} catch {
// Non-fatal
}
}
console.log(`[email-sync] ${meta.account.email} status set to synced`);
},
},
],
};
/** The handler's steps, for the sidecar's own runner. */
export const imapSyncSteps: SyncStep[] = emailSyncHandler.steps as SyncStep[];
@@ -1,54 +0,0 @@
type ValidateImapParams = {
host: string;
port: number;
secure: boolean;
user: string;
pass?: string;
accessToken?: string;
};
type ValidateImapResult = { ok: true; folderCount: number } | { ok: false; error: string };
export async function validateImapConnection(params: ValidateImapParams): Promise<ValidateImapResult> {
const { ImapFlow } = await import('imapflow');
const auth: { user: string; pass?: string; accessToken?: string } = { user: params.user };
if (params.accessToken) {
auth.accessToken = params.accessToken;
} else if (params.pass) {
auth.pass = params.pass;
} else {
return { ok: false, error: 'No authentication credentials provided' };
}
const client = new ImapFlow({
host: params.host,
port: params.port,
secure: params.secure,
auth,
logger: false,
greetingTimeout: 60_000,
socketTimeout: 60_000,
});
try {
const result = await Promise.race([
(async () => {
await client.connect();
const folders = await client.list();
await client.logout();
return { ok: true as const, folderCount: folders.length };
})(),
new Promise<ValidateImapResult>((_, reject) =>
setTimeout(() => reject(new Error('Connection timed out')), 90_000),
),
]);
return result;
} catch (err) {
try {
client.close();
} catch {}
const message = err instanceof Error ? err.message : 'Connection failed';
return { ok: false, error: message };
}
}
-72
View File
@@ -1,72 +0,0 @@
import type { SidecarEvent } from '../protocol';
import { initEmailCron, stopEmailCron } from './email-cron';
import { initEmailIdle, stopEmailIdle } from './email-idle';
import { broadcastEmailNew } from './routes';
import { startEmailServer } from './http';
import { createSidecarConnector } from '../connect';
import { API_URL } from '../../officer-url.mjs';
// The sidecar used to reach BACK into the platform's queue over this socket to get a sync run —
// enqueueViaWs / listJobsViaWs and a pending-response map. Syncs run in this process now
// (sync-runner.ts), so the shim is gone and nothing but a port crosses the socket at startup.
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: Record<string, unknown>, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id as string });
break;
default:
reply({
type: 'error',
id: cmd.id as string,
error: `Unknown command type: ${cmd.type}`,
});
}
}
// ── HTTP server ──
//
// Started before the registration socket so the port is known by the time we announce it. `/api/email/*`
// on the platform is a proxy onto this.
const serverPort = startEmailServer();
// ── Connect to API server ──
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'email',
handles: ['email'],
onCommand(cmd, reply) {
handleCommand(cmd as Record<string, unknown>, reply as ReplyFn);
},
onConnected() {
// The platform forgets the port when the socket drops, and this listener outlives an officer restart,
// so re-announce on every reconnect.
connection.send({ type: 'email:server', port: serverPort });
// Start email cron once connected (so queue commands can reach API server)
initEmailCron();
// Real-time push via IMAP IDLE; the cron above is the slow backstop. On new mail, tell the API
// server so it can push an SSE event to that user's open /email page.
// Straight to this process's own SSE clients — the /email/events stream lives here now, so the
// round trip out to officer and back (the `email:new` wire event) is gone.
initEmailIdle((userEmail) => broadcastEmailNew(userEmail));
},
});
// ── Graceful shutdown ──
function shutdown(signal: string) {
console.log(`[email] ${signal} received, shutting down...`);
stopEmailIdle();
stopEmailCron();
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-326
View File
@@ -1,326 +0,0 @@
import { createHash } from 'node:crypto';
import { getUserIntegration, upsertUserIntegration, getServerIntegration, getEmailAccount } from 'officerdb';
import { refreshGoogleAccessToken } from '@@/api/integrations/google-auth';
import { openEmailDb, getSyncMeta, setSyncMeta, upsertFromRawEml } from './store';
import { readSyncMeta, writeSyncMeta } from './sync-meta';
import { type GmailCredentials, loadGmailCredentials, gmailApiSync } from './gmail-api';
export type ResyncResult = { saved: number; skipped: number; errors: number };
// ── Gmail resync (REST API, history-based) ──
async function refreshCredentials(creds: GmailCredentials): Promise<GmailCredentials> {
if (!creds.accessToken || !creds.refreshToken) return creds;
const tokenExpired = !creds.expiresAt || creds.expiresAt < Date.now() + 60_000;
if (!tokenExpired) return creds;
console.log('[resync] Refreshing OAuth token');
const refreshed = await refreshGoogleAccessToken(creds.refreshToken);
const userGoogle = await getUserIntegration(creds.userId, 'google');
const existingConfig = (userGoogle?.config as Record<string, unknown>) ?? {};
const serverGoogle = await getServerIntegration('google');
await upsertUserIntegration({
userId: creds.userId,
provider: 'google',
serverIntegrationId: serverGoogle?.id,
config: { ...existingConfig, accessToken: refreshed.accessToken, expiresAt: refreshed.expiresAt },
});
return { ...creds, accessToken: refreshed.accessToken, expiresAt: refreshed.expiresAt };
}
async function gmailResync(userEmail: string, accountEmail: string): Promise<ResyncResult> {
let creds = await loadGmailCredentials(userEmail);
if (!creds.accessToken) {
throw new Error('OAuth not configured — connect Google in Settings → Integrations for resyncs');
}
creds = await refreshCredentials(creds);
const db = openEmailDb(userEmail, accountEmail);
try {
const result = await gmailApiSync({ creds, db });
setSyncMeta(db, 'last_sync_date', new Date().toISOString().split('T')[0]!);
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
console.log(`[resync] Gmail done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`);
return result;
} finally {
db.close();
}
}
// ── Generic IMAP resync ──
type ImapAccountInfo = {
id: number;
userId: number;
email: string;
imapHost: string;
imapPort: number;
imapSecure: boolean;
provider: string;
authType: string;
credentials: Record<string, unknown>;
};
type FolderInfo = {
specialUse?: string;
path: string;
flags: Set<string>;
status?: { uidNext?: number; uidValidity?: number };
};
const SPECIAL_USE_LABEL_MAP: Record<string, string> = {
'\\Inbox': 'inbox',
'\\Sent': 'sent',
'\\Drafts': 'draft',
'\\Flagged': 'starred',
'\\Trash': 'trash',
'\\Junk': 'spam',
'\\All': 'archive',
};
const SKIP_SPECIAL_USE = new Set(['\\Trash', '\\Junk', '\\All']);
function shouldSkipFolder(folder: FolderInfo): boolean {
if (folder.flags.has('\\Noselect') || folder.flags.has('\\NonExistent')) return true;
if (folder.specialUse && SKIP_SPECIAL_USE.has(folder.specialUse)) return true;
return false;
}
function folderToLabel(folder: FolderInfo): string {
if (folder.specialUse && SPECIAL_USE_LABEL_MAP[folder.specialUse]) {
return SPECIAL_USE_LABEL_MAP[folder.specialUse]!;
}
if (folder.path === 'INBOX') return 'inbox';
return folder.path.toLowerCase();
}
function messageIdToStableId(raw: string): string | null {
const match = raw.match(/^Message-Id:\s*<?([^>\s]+)>?/im);
if (!match?.[1]) return null;
return createHash('sha1').update(match[1]).digest('hex').slice(0, 16);
}
async function resolveImapAuth(
account: ImapAccountInfo,
): Promise<{ user: string; pass?: string; accessToken?: string }> {
if (account.authType !== 'oauth') {
return { user: account.email, pass: account.credentials.password as string };
}
const userGoogle = await getUserIntegration(account.userId, 'google');
const config = userGoogle?.config as Record<string, unknown> | undefined;
if (!config?.refreshToken) throw new Error('Google OAuth not configured — reconnect your Google account');
const expiresAt = config.expiresAt as number | undefined;
const tokenExpired = !expiresAt || expiresAt < Date.now() + 60_000;
if (!tokenExpired && config.accessToken) {
return { user: account.email, accessToken: config.accessToken as string };
}
const refreshed = await refreshGoogleAccessToken(config.refreshToken as string);
const serverGoogle = await getServerIntegration('google');
await upsertUserIntegration({
userId: account.userId,
provider: 'google',
serverIntegrationId: serverGoogle?.id,
config: { ...config, accessToken: refreshed.accessToken, expiresAt: refreshed.expiresAt },
});
return { user: account.email, accessToken: refreshed.accessToken };
}
async function imapResync(account: ImapAccountInfo, userEmail: string): Promise<ResyncResult> {
const { ImapFlow } = await import('imapflow');
const imapAuth = await resolveImapAuth(account);
const freshAccount = await getEmailAccount(account.id);
if (!freshAccount) throw new Error(`Email account ${account.id} not found`);
const db = openEmailDb(userEmail, account.email);
// The position lives in this file now; the Postgres column is only a one-time backfill source for
// accounts that predate the move. See sync-meta.ts.
const syncMeta: Record<string, unknown> = readSyncMeta(db, freshAccount.syncMeta as Record<string, unknown>);
const existingIds = new Set<string>();
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
for (const row of rows) existingIds.add(row.id);
let saved = 0;
let skipped = 0;
let errors = 0;
const client = new ImapFlow({
host: account.imapHost,
port: account.imapPort,
secure: account.imapSecure,
auth: imapAuth,
logger: false,
socketTimeout: 5 * 60 * 1000,
});
try {
await client.connect();
console.log('[resync] IMAP connected');
const folders = (await client.list({ statusQuery: { uidNext: true, uidValidity: true } })) as FolderInfo[];
for (const folder of folders) {
if (shouldSkipFolder(folder)) continue;
const uidValidityKey = `imap_uidvalidity:${folder.path}`;
const lastUidKey = `imap_lastuid:${folder.path}`;
const storedUidValidity = syncMeta[uidValidityKey] as string | undefined;
const storedLastUid = syncMeta[lastUidKey] as string | undefined;
const uidValidity = folder.status?.uidValidity ? String(folder.status.uidValidity) : null;
const uidNext = folder.status?.uidNext ?? 0;
const lastUid =
storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0;
if (lastUid > 0 && uidNext <= lastUid + 1) continue;
let lock;
try {
lock = await client.getMailboxLock(folder.path);
} catch {
continue;
}
try {
const mailbox = client.mailbox;
if (!mailbox) continue;
const mbUidValidity = String(mailbox.uidValidity);
const effectiveLastUid = storedUidValidity === mbUidValidity ? lastUid : 0;
let maxUid = effectiveLastUid;
const label = folderToLabel(folder);
const range = effectiveLastUid > 0 ? `${effectiveLastUid + 1}:*` : '1:*';
try {
const fetchOpts: Record<string, unknown> = { source: true, uid: true };
if (account.provider === 'gmail') fetchOpts.labels = true;
for await (const msg of client.fetch(range, fetchOpts, { uid: true })) {
if (msg.uid <= effectiveLastUid) continue;
maxUid = Math.max(maxUid, msg.uid);
if (!msg.source) {
errors++;
continue;
}
const raw = msg.source.toString('utf-8');
const id = messageIdToStableId(raw);
if (!id) {
errors++;
continue;
}
if (existingIds.has(id)) {
skipped++;
continue;
}
try {
upsertFromRawEml({
db,
id,
raw,
integration: account.provider,
emailAccount: account.email,
labels: [label],
});
existingIds.add(id);
saved++;
} catch {
errors++;
}
}
} catch (fetchErr) {
const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
if (!errMsg.includes('Nothing to fetch')) {
console.log(`[resync] Fetch error in ${folder.path}: ${errMsg}`);
errors++;
}
}
syncMeta[uidValidityKey] = mbUidValidity;
if (maxUid > effectiveLastUid) {
syncMeta[lastUidKey] = String(maxUid);
}
} finally {
lock.release();
}
}
await client.logout().catch(() => {});
} catch (err) {
await client.logout().catch(() => {});
throw err;
} finally {
db.close();
}
syncMeta.last_sync_at = new Date().toISOString();
// Reopened deliberately: the sync loop above closes the db in its `finally`, and the position must be
// written after the messages it describes are safely stored.
const metaDb = openEmailDb(userEmail, account.email);
try {
writeSyncMeta(metaDb, syncMeta);
} finally {
metaDb.close();
}
console.log(`[resync] IMAP done: saved ${saved}, skipped ${skipped}, errors ${errors}`);
return { saved, skipped, errors };
}
// ── Public API ──
type ResyncParams = {
accountId: number;
userEmail: string;
userId: number;
};
// Coalesce concurrent resyncs of the same account within this process (IDLE + cron + manual can all
// fire) — a caller arriving mid-resync just awaits the one already running.
const resyncInFlight = new Map<number, Promise<ResyncResult>>();
export function performResync(params: ResyncParams): Promise<ResyncResult> {
const running = resyncInFlight.get(params.accountId);
if (running) return running;
const p = doResync(params).finally(() => resyncInFlight.delete(params.accountId));
resyncInFlight.set(params.accountId, p);
return p;
}
async function doResync({ accountId, userEmail, userId }: ResyncParams): Promise<ResyncResult> {
const account = await getEmailAccount(accountId);
if (!account || account.userId !== userId) throw new Error('Account not found');
// Gmail API resync needs OAuth; a gmail account authed with an app password resyncs over IMAP.
if (account.provider === 'gmail' && account.authType === 'oauth') {
return gmailResync(userEmail, account.email);
}
return imapResync(
{
id: account.id,
userId: account.userId,
email: account.email,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapSecure: account.imapSecure,
provider: account.provider,
authType: account.authType,
credentials: account.credentials as Record<string, unknown>,
},
userEmail,
);
}
-433
View File
@@ -1,433 +0,0 @@
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import nodemailer from 'nodemailer';
import type { EmailMessage } from 'types';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { getEmailAttachmentCacheDir } from '@@/data-path';
import { getEmailAccounts } from 'officerdb';
import { openEmailDb, openUserEmailDb, rowToSummary, getSyncMeta, searchEmails } from './store';
import { accountsRouter } from './accounts';
export const emailRouter = createRouter();
emailRouter.route('/accounts', accountsRouter);
// ── Sending (SMTP) — sends as the connected account using its app password ──
async function getSmtpTransport(
userId: number,
): Promise<{ transport: ReturnType<typeof nodemailer.createTransport>; from: string }> {
const accounts = await getEmailAccounts(userId);
const acct = accounts.find((a) => a.enabled) ?? accounts[0];
if (!acct) throw errors.BAD_REQUEST('No email account configured');
const pass = (acct.credentials as Record<string, unknown> | null)?.password as string | undefined;
if (!pass) throw errors.BAD_REQUEST('This account has no SMTP password — sending needs an app-password account');
// Gmail: smtp.gmail.com:465 (SSL). Derive from the IMAP host for other providers.
const host = acct.provider === 'gmail' ? 'smtp.gmail.com' : acct.imapHost.replace(/^imap\./, 'smtp.');
const transport = nodemailer.createTransport({ host, port: 465, secure: true, auth: { user: acct.email, pass } });
return { transport, from: acct.email };
}
// POST /send — compose/reply (multipart; `files` are attachments). Gmail auto-files the sent copy in
// "Sent", so IMAP sync picks it up.
emailRouter.post('/send', async (ctx) => {
const user = ctx.get('user');
const form = await ctx.req.parseBody({ all: true });
const str = (v: unknown) => (typeof v === 'string' ? v : '');
const to = str(form.to).trim();
if (!to) throw errors.BAD_REQUEST('At least one recipient is required');
const toFiles = (raw: unknown) =>
(Array.isArray(raw) ? raw : raw ? [raw] : []).filter((f): f is File => f instanceof File);
const attachments = await Promise.all(
toFiles(form.files).map(async (f) => ({
filename: f.name,
content: Buffer.from(await f.arrayBuffer()),
contentType: f.type || undefined,
})),
);
// Inline images: cid `inline-<i>` matches the `<img src="cid:inline-i">` the composer put in the html.
const inline = await Promise.all(
toFiles(form.inline).map(async (f, i) => ({
filename: f.name,
content: Buffer.from(await f.arrayBuffer()),
contentType: f.type || undefined,
cid: `inline-${i}`,
contentDisposition: 'inline' as const,
})),
);
const allAttachments = [...attachments, ...inline];
const html = str(form.html).trim();
const inReplyTo = str(form.inReplyTo).trim();
const { transport, from } = await getSmtpTransport(user.id);
await transport.sendMail({
from,
to,
cc: str(form.cc).trim() || undefined,
bcc: str(form.bcc).trim() || undefined,
subject: str(form.subject).trim() || '(no subject)',
text: str(form.body),
...(html ? { html } : {}),
...(allAttachments.length ? { attachments: allAttachments } : {}),
...(inReplyTo ? { headers: { 'In-Reply-To': inReplyTo, References: inReplyTo } } : {}),
});
return ctx.json({ ok: true });
});
// GET /contacts?q= — address autocomplete from people you've received mail from, ranked by frequency.
emailRouter.get('/contacts', async (ctx) => {
const user = ctx.get('user');
const like = `%${(ctx.req.query('q') ?? '').trim().toLowerCase()}%`;
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.json([]);
try {
const rows = db
.query(
`SELECT lower(from_address) AS address, from_name AS name, count(*) AS c
FROM emails
WHERE from_address IS NOT NULL AND from_address != '' AND deleted = 0
AND (lower(from_address) LIKE ? OR lower(from_name) LIKE ?)
GROUP BY lower(from_address)
ORDER BY c DESC LIMIT 10`,
)
.all(like, like) as Array<{ address: string; name: string | null }>;
return ctx.json(rows.map((r) => ({ address: r.address, name: r.name || '' })));
} finally {
db.close();
}
});
// ── Real-time: per-user SSE stream of email events (fed by the IMAP IDLE watcher) ──
const emailSseClients = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>();
// Called from the sidecar-message handler when the IDLE watcher saves new mail for a user.
export function broadcastEmailNew(userEmail: string): void {
const set = emailSseClients.get(userEmail);
if (!set || set.size === 0) return;
const payload = new TextEncoder().encode(`data: ${JSON.stringify({ type: 'new-mail' })}\n\n`);
for (const ctrl of set) {
try {
ctrl.enqueue(payload);
} catch {
/* dead controller — cleaned up on cancel */
}
}
}
// EventSource endpoint (auth via ?token= handled by userMiddleware). The frontend opens this only
// while on /email, so events stop the moment you navigate away.
emailRouter.get('/events', (ctx) => {
const email = ctx.get('user').email;
const enc = new TextEncoder();
let controllerRef: ReadableStreamDefaultController<Uint8Array>;
let ping: ReturnType<typeof setInterval>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controllerRef = controller;
let set = emailSseClients.get(email);
if (!set) {
set = new Set();
emailSseClients.set(email, set);
}
set.add(controller);
controller.enqueue(enc.encode('retry: 3000\n\n'));
ping = setInterval(() => {
try {
controller.enqueue(enc.encode(': ping\n\n'));
} catch {
/* closed */
}
}, 25_000);
},
cancel() {
clearInterval(ping);
const set = emailSseClients.get(email);
set?.delete(controllerRef);
if (set && set.size === 0) emailSseClients.delete(email);
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
});
});
// Full-text search across all mail (subject / sender / recipients / snippet / body), newest first.
emailRouter.get('/search', async (ctx) => {
const user = ctx.get('user');
const q = (ctx.req.query('q') ?? '').trim();
const page = Math.max(1, Number(ctx.req.query('page') ?? '1') || 1);
const limit = Math.min(100, Math.max(1, Number(ctx.req.query('limit') ?? '50') || 50));
const offset = (page - 1) * limit;
if (!q) return ctx.json({ messages: [], total: 0 });
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.json({ messages: [], total: 0 });
try {
const { rows, total } = searchEmails(db, q, limit, offset);
return ctx.json({ messages: rows.map(rowToSummary), total });
} finally {
db.close();
}
});
// `folder` is request input and was being interpolated straight into the SQL. It is bound now — the
// fragment and its parameters travel together because each call site builds several statements from the
// same fragment and has to spread the params in the right order.
type FolderFilter = { where: string; params: string[] };
const folderFilter = (folder: string): FolderFilter =>
folder === 'all'
? { where: 'deleted = 0', params: [] }
: { where: 'deleted = 0 AND labels LIKE ?', params: [`%${folder}%`] };
emailRouter.get('/messages', async (ctx) => {
const user = ctx.get('user');
// `|| n` also catches NaN from a non-numeric query param, which used to reach the bindings as NaN.
const page = Math.max(Number(ctx.req.query('page') ?? '1') || 1, 1);
const limit = Math.max(Number(ctx.req.query('limit') ?? '50') || 50, 1);
const folder = ctx.req.query('folder') ?? 'inbox';
const offset = (page - 1) * limit;
const { where: folderWhere, params: folderParams } = folderFilter(folder);
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.json({ messages: [], total: 0 });
try {
// One row per conversation: the latest message in each thread within this folder, plus the
// thread's message count and how many are unread. COALESCE guards any un-backfilled rows.
const rows = db
.query(
`SELECT * FROM (
SELECT e.*,
COUNT(*) OVER (PARTITION BY COALESCE(thread_id, id)) AS thread_count,
SUM(CASE WHEN read = 0 THEN 1 ELSE 0 END) OVER (PARTITION BY COALESCE(thread_id, id)) AS thread_unread,
ROW_NUMBER() OVER (PARTITION BY COALESCE(thread_id, id) ORDER BY date DESC, id DESC) AS rn
FROM emails e
WHERE ${folderWhere}
) WHERE rn = 1
ORDER BY date DESC LIMIT ? OFFSET ?`,
)
.all(...folderParams, limit, offset) as Record<string, unknown>[];
const countRow = db
.query(`SELECT COUNT(DISTINCT COALESCE(thread_id, id)) as total FROM emails WHERE ${folderWhere}`)
.get(...folderParams) as { total: number };
const messages = rows.map(rowToSummary);
return ctx.json({ messages, total: countRow.total });
} finally {
db.close();
}
});
function buildMessage(db: ReturnType<typeof openEmailDb>, row: Record<string, unknown>): EmailMessage {
const attachmentRows = db
.query('SELECT * FROM attachments WHERE email_id = ? ORDER BY idx')
.all(row.id as string) as Array<Record<string, unknown>>;
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
return {
id: row.id as string,
from,
to: row.to_address as string,
cc: (row.cc as string) ?? undefined,
subject: row.subject as string,
date: row.date as string,
snippet: row.snippet as string,
html: (row.html as string) ?? undefined,
text: (row.text_body as string) ?? undefined,
attachments: attachmentRows.map((a) => ({
filename: a.filename as string,
size: a.size as number,
contentType: a.content_type as string,
})),
...(row.attachment_count ? { attachmentCount: row.attachment_count as number } : {}),
...(row.read ? { read: true } : {}),
...(row.labels ? { labels: (row.labels as string).split(',') } : {}),
};
}
emailRouter.get('/messages/:id', async (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.text('Not found', 404);
try {
const row = db.query('SELECT * FROM emails WHERE id = ? AND deleted = 0').get(id) as Record<string, unknown> | null;
if (!row) return ctx.text('Not found', 404);
return ctx.json(buildMessage(db, row));
} finally {
db.close();
}
});
// GET /thread/:id — the full conversation containing message :id, oldest message first.
emailRouter.get('/thread/:id', async (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.text('Not found', 404);
try {
const head = db.query('SELECT thread_id, subject FROM emails WHERE id = ? AND deleted = 0').get(id) as {
thread_id: string | null;
subject: string;
} | null;
if (!head) return ctx.text('Not found', 404);
const threadKey = head.thread_id ?? id;
const rows = db
.query('SELECT * FROM emails WHERE COALESCE(thread_id, id) = ? AND deleted = 0 ORDER BY date ASC')
.all(threadKey) as Record<string, unknown>[];
const messages = rows.map((row) => buildMessage(db, row));
return ctx.json({ id, subject: head.subject, messages });
} finally {
db.close();
}
});
// PATCH /thread/:id/read — mark every message in the conversation as read.
emailRouter.patch('/thread/:id/read', async (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.text('Not found', 404);
try {
const head = db.query('SELECT thread_id FROM emails WHERE id = ?').get(id) as { thread_id: string | null } | null;
if (!head) return ctx.text('Not found', 404);
const threadKey = head.thread_id ?? id;
db.run('UPDATE emails SET read = 1 WHERE COALESCE(thread_id, id) = ? AND read = 0', [threadKey]);
return ctx.json({ ok: true });
} finally {
db.close();
}
});
emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const index = Number(ctx.req.param('index'));
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.text('Attachment not found', 404);
try {
const row = db.query('SELECT filename, content FROM attachments WHERE email_id = ? AND idx = ?').get(id, index) as {
filename: string;
content: string | null;
} | null;
if (!row || !row.content) return ctx.text('Attachment not found', 404);
const fileName = row.filename ?? 'unknown';
const attachDir = getEmailAttachmentCacheDir(user.email);
const destPath = join(attachDir, fileName);
const destFile = Bun.file(destPath);
if (!(await destFile.exists())) {
await mkdir(attachDir, { recursive: true });
const binary = Buffer.from(row.content, 'base64');
await Bun.write(destPath, binary);
}
return ctx.json({ filePath: `email_accounts/attachment_cache/${fileName}`, fileName, root: 'user-data' });
} catch {
return ctx.text('Failed to extract attachment', 500);
} finally {
db.close();
}
});
emailRouter.patch('/messages/:id/read', async (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.json({ ok: true });
try {
db.run('UPDATE emails SET read = 1 WHERE id = ? AND read = 0', [id]);
return ctx.json({ ok: true });
} finally {
db.close();
}
});
emailRouter.delete('/messages/:id', async (ctx) => {
const user = ctx.get('user');
const id = ctx.req.param('id');
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.text('Not found', 404);
try {
const result = db.run('UPDATE emails SET deleted = 1 WHERE id = ? AND deleted = 0', [id]);
if (result.changes === 0) return ctx.text('Not found', 404);
return ctx.json({ ok: true });
} finally {
db.close();
}
});
emailRouter.get('/sync-status', async (ctx) => {
const user = ctx.get('user');
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.json({ lastSyncAt: null });
try {
const lastSyncAt = getSyncMeta(db, 'last_sync_at');
return ctx.json({ lastSyncAt });
} finally {
db.close();
}
});
emailRouter.get('/stats', async (ctx) => {
const user = ctx.get('user');
const folder = ctx.req.query('folder') ?? 'inbox';
const { where: folderWhere, params: folderParams } = folderFilter(folder);
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.json({ total: 0, byDomain: [], bySender: [] });
try {
const total = (
db.query(`SELECT COUNT(*) as count FROM emails WHERE ${folderWhere}`).get(...folderParams) as { count: number }
).count;
const byDomain = db
.query(
`SELECT from_domain, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 20`,
)
.all(...folderParams) as Array<{ from_domain: string; count: number }>;
const bySender = db
.query(
`SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 20`,
)
.all(...folderParams) as Array<{ from_address: string; from_name: string; count: number }>;
return ctx.json({ total, byDomain, bySender });
} finally {
db.close();
}
});
emailRouter.get('/labels', async (ctx) => {
const user = ctx.get('user');
const db = await openUserEmailDb(user.email, user.id);
if (!db) return ctx.json({ labels: [] });
try {
const rows = db.query('SELECT labels FROM emails WHERE deleted = 0 AND labels IS NOT NULL').all() as Array<{
labels: string;
}>;
const counts = new Map<string, number>();
for (const row of rows) {
for (const label of row.labels.split(',')) {
const trimmed = label.trim().toLowerCase();
if (trimmed) counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
}
}
const labels = [...counts.entries()].map(([label, count]) => ({ label, count })).sort((a, b) => b.count - a.count);
return ctx.json({ labels });
} finally {
db.close();
}
});
-795
View File
@@ -1,795 +0,0 @@
import { Database } from 'bun:sqlite';
import { createHash } from 'node:crypto';
import { join, dirname } from 'node:path';
import { chmodSync, mkdirSync } from 'node:fs';
import { getEmailDbPath } from '@@/data-path';
import { getEmailAccounts } from 'officerdb';
import type { EmailSummary } from 'types';
const SCHEMA_TABLES = `
CREATE TABLE IF NOT EXISTS emails (
id TEXT PRIMARY KEY,
integration TEXT NOT NULL DEFAULT 'gmail',
email_account TEXT NOT NULL DEFAULT '',
from_name TEXT,
from_address TEXT,
from_domain TEXT,
to_address TEXT,
cc TEXT,
subject TEXT,
date TEXT,
snippet TEXT,
html TEXT,
text_body TEXT,
attachment_count INTEGER DEFAULT 0,
read INTEGER DEFAULT 0,
deleted INTEGER DEFAULT 0,
labels TEXT,
thread_id TEXT
);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email_id TEXT REFERENCES emails(id) ON DELETE CASCADE,
idx INTEGER,
filename TEXT,
size INTEGER,
content_type TEXT,
content TEXT
);
CREATE TABLE IF NOT EXISTS sync_meta (
key TEXT PRIMARY KEY,
value TEXT
);
`;
const SCHEMA_INDEXES = `
CREATE INDEX IF NOT EXISTS idx_emails_date ON emails(date);
CREATE INDEX IF NOT EXISTS idx_emails_from_domain ON emails(from_domain);
CREATE INDEX IF NOT EXISTS idx_emails_from_address ON emails(from_address);
CREATE INDEX IF NOT EXISTS idx_emails_integration ON emails(integration);
CREATE INDEX IF NOT EXISTS idx_emails_email_account ON emails(email_account);
CREATE INDEX IF NOT EXISTS idx_emails_labels ON emails(labels);
CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails(thread_id);
`;
/** Convert label IDs to lowercase comma-separated string for storage */
function labelsToString(labels?: string[]): string | null {
if (!labels || labels.length === 0) return null;
return labels.map((l) => l.toLowerCase()).join(',');
}
/** Convert stored comma-separated labels back to array */
function labelsFromString(value: unknown): string[] | undefined {
if (typeof value !== 'string' || !value) return undefined;
return value.split(',');
}
function extractAddress(headerValue: string): { name: string; address: string } {
const match = headerValue.match(/^"?(.+?)"?\s*<(.+?)>$/);
if (match) return { name: match[1]!.trim(), address: match[2]!.toLowerCase() };
const bare = headerValue.trim().toLowerCase();
return { name: '', address: bare };
}
function extractDomain(address: string): string {
const at = address.lastIndexOf('@');
return at >= 0 ? address.slice(at + 1) : '';
}
// ── Conversation threading ──
// `id` is sha1(Message-Id) (see resync.messageIdToStableId), so hashing a referenced Message-Id the
// same way yields the *id of that referenced email*. That makes header-based threading trivial:
// a reply's thread_id is the hash of its root Message-Id, which equals the root email's own id.
const hashMsgId = (msgId: string): string => createHash('sha1').update(msgId).digest('hex').slice(0, 16);
/** Ordered Message-Ids this email references (References first, root→leaf; else In-Reply-To). */
function extractReferenceIds(raw: string): string[] {
const refs = extractFullHeader(raw, 'References') || extractFullHeader(raw, 'In-Reply-To');
return Array.from(refs.matchAll(/<([^>]+)>/g), (m) => m[1]!.trim()).filter(Boolean);
}
/** Compute a header-based thread_id for a freshly ingested email (falls back to its own id = new thread). */
function computeThreadId(db: Database, id: string, raw: string): string {
const refIds = extractReferenceIds(raw);
if (refIds.length === 0) return id; // no ancestors → this email is a thread root
const hashed = refIds.map(hashMsgId);
// Adopt an ancestor's thread if we already have one stored (robust to In-Reply-To-only clients).
const placeholders = hashed.map(() => '?').join(',');
const found = db
.query(`SELECT thread_id FROM emails WHERE id IN (${placeholders}) AND thread_id IS NOT NULL LIMIT 1`)
.get(...hashed) as { thread_id: string } | null;
return found?.thread_id ?? hashMsgId(refIds[0]!);
}
const RE_PREFIX = /^\s*((re|fwd?|aw|wg|sv|vs|res|antw)\s*(\[\d+\])?\s*:\s*)+/i;
/** Normalize a subject for fallback grouping: strip reply/forward prefixes, fold whitespace, lowercase. */
function normalizeSubject(subject: string): string {
return subject.replace(RE_PREFIX, '').replace(/\s+/g, ' ').trim().toLowerCase();
}
const firstAddress = (value: unknown): string => {
if (typeof value !== 'string') return '';
const first = value.split(',')[0] ?? '';
return (first.match(/<([^>]+)>/)?.[1] ?? first).trim().toLowerCase();
};
/**
* Subject-based thread key for mail synced before header capture (no References available).
* Groups by normalized subject + the counterpart address, so recurring 1:1 conversations collapse
* while unrelated same-subject mail from different people stays apart. Trivial subjects stay ungrouped.
*/
function fallbackThreadId(row: {
id: string;
subject: unknown;
from_address: unknown;
to_address: unknown;
email_account: unknown;
}): string {
const norm = normalizeSubject(typeof row.subject === 'string' ? row.subject : '');
if (!norm) return row.id;
const me = typeof row.email_account === 'string' ? row.email_account.toLowerCase() : '';
const from = typeof row.from_address === 'string' ? row.from_address.toLowerCase() : '';
const counterpart = from && from !== me ? from : firstAddress(row.to_address) || from;
return `s:${norm}|${counterpart}`;
}
export function openEmailDb(ownerEmail: string, accountEmail: string): Database {
const dbPath = getEmailDbPath(ownerEmail, accountEmail);
mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath, { create: true });
// The API and the email sidecar both open this file; wait out a concurrent writer (e.g. a resync
// or the one-time thread_id backfill) instead of failing immediately with "database is locked".
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA journal_mode = DELETE');
db.exec('PRAGMA foreign_keys = ON');
db.exec(SCHEMA_TABLES);
migrate(db);
db.exec(SCHEMA_INDEXES);
ensureFts(db);
// Try to chmod, but don't crash if permission denied (e.g., file owned by different user)
try {
chmodSync(dbPath, 0o666);
} catch (err) {
// File exists with correct permissions, or owned by another user - that's fine
}
return db;
}
/**
* Open the email DB for a user's configured account. Emails are stored per account
* (email_accounts/<account>/emails.db); for now we use the user's first account. Returns null if the
* user has no email account configured yet.
*/
export async function openUserEmailDb(ownerEmail: string, userId: number): Promise<Database | null> {
const accounts = await getEmailAccounts(userId);
const account = accounts.find((a) => a.enabled) ?? accounts[0];
return account ? openEmailDb(ownerEmail, account.email) : null;
}
function migrate(db: Database): void {
const cols = db.query('PRAGMA table_info(emails)').all() as Array<{ name: string }>;
const colNames = new Set(cols.map((c) => c.name));
if (!colNames.has('deleted')) {
db.exec('ALTER TABLE emails ADD COLUMN deleted INTEGER DEFAULT 0');
}
if (!colNames.has('integration')) {
db.exec("ALTER TABLE emails ADD COLUMN integration TEXT NOT NULL DEFAULT 'gmail'");
}
if (!colNames.has('email_account')) {
db.exec("ALTER TABLE emails ADD COLUMN email_account TEXT NOT NULL DEFAULT ''");
}
if (!colNames.has('labels')) {
db.exec('ALTER TABLE emails ADD COLUMN labels TEXT');
}
if (!colNames.has('thread_id')) {
db.exec('ALTER TABLE emails ADD COLUMN thread_id TEXT');
}
// Backfill thread_id for any rows missing it. Header data isn't kept for already-synced mail, so
// these use the subject-based fallback. New mail gets an exact header-based thread_id at insert.
backfillThreadIds(db);
// Ensure sync_meta table exists (for DBs created before it was added to SCHEMA_TABLES)
db.exec('CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT)');
// Add content column to attachments if missing
const attCols = db.query('PRAGMA table_info(attachments)').all() as Array<{ name: string }>;
const attColNames = new Set(attCols.map((c) => c.name));
if (!attColNames.has('content')) {
db.exec('ALTER TABLE attachments ADD COLUMN content TEXT');
}
}
function backfillThreadIds(db: Database): void {
const rows = db
.query('SELECT id, subject, from_address, to_address, email_account FROM emails WHERE thread_id IS NULL')
.all() as Array<{
id: string;
subject: unknown;
from_address: unknown;
to_address: unknown;
email_account: unknown;
}>;
if (rows.length === 0) return;
const update = db.prepare('UPDATE emails SET thread_id = ? WHERE id = ?');
db.exec('BEGIN');
try {
for (const row of rows) update.run(fallbackThreadId(row), row.id);
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}
// ── Full-text search (FTS5) ──
// A standalone FTS5 index kept in sync with `emails` on every upsert. unicode61 + diacritic folding
// gives accent-insensitive matching; per-term prefix queries make it feel incremental.
function ensureFts(db: Database): void {
db.exec(
`CREATE VIRTUAL TABLE IF NOT EXISTS emails_fts USING fts5(
id UNINDEXED, subject, sender, recipients, snippet, body,
tokenize = 'unicode61 remove_diacritics 2'
)`,
);
const fts = (db.query('SELECT count(*) AS c FROM emails_fts').get() as { c: number }).c;
const total = (db.query('SELECT count(*) AS c FROM emails').get() as { c: number }).c;
if (fts === 0 && total > 0) {
// Backfill existing mail (first run after this feature ships).
db.exec(
`INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body)
SELECT id, COALESCE(subject, ''),
TRIM(COALESCE(from_name, '') || ' ' || COALESCE(from_address, '')),
TRIM(COALESCE(to_address, '') || ' ' || COALESCE(cc, '')),
COALESCE(snippet, ''), COALESCE(text_body, '')
FROM emails`,
);
}
}
const ftsDeleteStmt = 'DELETE FROM emails_fts WHERE id = ?';
const ftsInsertStmt =
'INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body) VALUES (?, ?, ?, ?, ?, ?)';
function syncFtsRow(
db: Database,
id: string,
subject: string,
sender: string,
recipients: string,
snippet: string,
body: string,
): void {
db.run(ftsDeleteStmt, [id]);
db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]);
}
// Gmail-style query parsing. Free text → full-text (prefix-AND). Operators:
// from:/to:/subject:/body: → FTS5 column filters
// has:attachment, is:unread/read, label:X, before:/after:YYYY-MM-DD → SQL filters on `emails`
// Unknown operators fall back to free text. Quoted values (from:"a b") match as an exact phrase.
const FTS_COLUMNS: Record<string, string> = { from: 'sender', to: 'recipients', subject: 'subject', body: 'body' };
const ftsTerm = (value: string, column: string | null, prefix: boolean): string => {
const esc = value.replace(/"/g, '""');
return `${column ? column + ':' : ''}"${esc}"${prefix ? '*' : ''}`;
};
const parseSearchDate = (v: string): string | null => {
const iso = /^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00:00.000Z` : v;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
};
// One AND-group of terms (a single OR branch). `fts` is the FTS5 MATCH expression for this branch;
// `where`/`params` are its structured SQL filters.
type Branch = { fts: string; where: string[]; params: string[] };
function parseBranch(q: string): Branch {
const fts: string[] = [];
const where: string[] = [];
const params: string[] = [];
const re = /(\w+):("[^"]*"|\S+)|"([^"]*)"|(\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(q)) !== null) {
if (m[1]) {
const op = m[1].toLowerCase();
const raw = m[2]!;
const quoted = raw.startsWith('"') && raw.endsWith('"');
const val = quoted ? raw.slice(1, -1) : raw;
if (!val) continue;
const lower = val.toLowerCase();
if (FTS_COLUMNS[op]) {
fts.push(ftsTerm(val, FTS_COLUMNS[op], !quoted));
} else if (op === 'label') {
where.push('e.labels LIKE ?');
params.push(`%${lower}%`);
} else if (op === 'has' && (lower === 'attachment' || lower === 'attachments')) {
where.push('e.attachment_count > 0');
} else if (op === 'is' && (lower === 'unread' || lower === 'read')) {
where.push(lower === 'unread' ? 'e.read = 0' : 'e.read = 1');
} else if (op === 'before' || op === 'older') {
const d = parseSearchDate(val);
if (d) {
where.push('e.date < ?');
params.push(d);
}
} else if (op === 'after' || op === 'newer') {
const d = parseSearchDate(val);
if (d) {
where.push('e.date >= ?');
params.push(d);
}
} else {
// Unknown operator — treat the whole "op:val" token as free text.
fts.push(ftsTerm(`${op}:${val}`, null, !quoted));
}
} else if (m[3] !== undefined) {
if (m[3].trim()) fts.push(ftsTerm(m[3], null, false)); // quoted phrase → exact
} else if (m[4]) {
fts.push(ftsTerm(m[4], null, true)); // bare word → prefix
}
}
return { fts: fts.join(' '), where, params };
}
export function searchEmails(
db: Database,
q: string,
limit: number,
offset: number,
): { rows: Record<string, unknown>[]; total: number } {
// Split on top-level uppercase OR into branches (Gmail-style; lowercase "or" stays a search word).
const branches = q
.split(/\s+OR\s+/)
.map(parseBranch)
.filter((b) => b.fts || b.where.length > 0);
if (branches.length === 0) return { rows: [], total: 0 };
// Each branch becomes one self-contained condition: its FTS terms via an `id IN (FTS subquery)` so
// full-text and structured filters share a WHERE and branches can be OR'd. All ANDed within a branch.
const conds: string[] = [];
const params: string[] = [];
for (const b of branches) {
const parts: string[] = [];
if (b.fts) {
parts.push('e.id IN (SELECT emails_fts.id FROM emails_fts WHERE emails_fts MATCH ?)');
params.push(b.fts);
}
parts.push(...b.where);
params.push(...b.params);
conds.push(`(${parts.join(' AND ')})`);
}
const whereSql = `e.deleted = 0 AND (${conds.join(' OR ')})`;
const rows = db
.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`)
.all(...params, limit, offset) as Record<string, unknown>[];
const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${whereSql}`).get(...params) as { c: number }).c;
return { rows, total };
}
type ParsedEmail = {
id: string;
integration: string;
emailAccount: string;
fromName: string;
fromAddress: string;
to: string;
cc?: string;
subject: string;
date: string;
snippet: string;
html?: string;
text?: string;
attachments: Array<{ filename: string; size: number; contentType: string; content: string }>;
labels?: string[];
threadId?: string;
};
const upsertEmailStmt = `
INSERT OR REPLACE INTO emails (id, integration, email_account, from_name, from_address, from_domain, to_address, cc, subject, date, snippet, html, text_body, attachment_count, labels, thread_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const deleteAttachmentsStmt = 'DELETE FROM attachments WHERE email_id = ?';
const insertAttachmentStmt =
'INSERT INTO attachments (email_id, idx, filename, size, content_type, content) VALUES (?, ?, ?, ?, ?, ?)';
export function upsertEmail(db: Database, email: ParsedEmail): void {
const domain = extractDomain(email.fromAddress);
db.exec('BEGIN');
try {
db.run(upsertEmailStmt, [
email.id,
email.integration,
email.emailAccount,
email.fromName,
email.fromAddress,
domain,
email.to,
email.cc ?? null,
email.subject,
email.date,
email.snippet,
email.html ?? null,
email.text ?? null,
email.attachments.length,
labelsToString(email.labels),
email.threadId ?? email.id,
]);
db.run(deleteAttachmentsStmt, [email.id]);
for (let i = 0; i < email.attachments.length; i++) {
const att = email.attachments[i]!;
db.run(insertAttachmentStmt, [email.id, i, att.filename, att.size, att.contentType, att.content]);
}
syncFtsRow(
db,
email.id,
email.subject ?? '',
`${email.fromName ?? ''} ${email.fromAddress ?? ''}`.trim(),
`${email.to ?? ''} ${email.cc ?? ''}`.trim(),
email.snippet ?? '',
email.text ?? '',
);
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}
/** Upsert a single email from its raw RFC822 text using fast header parsing. */
type UpsertFromRawEmlParams = {
db: Database;
id: string;
raw: string;
integration: string;
emailAccount: string;
labels?: string[];
};
export function upsertFromRawEml({ db, id, raw, integration, emailAccount, labels }: UpsertFromRawEmlParams): void {
const from = extractHeader(raw, 'From');
const { name, address } = extractAddress(from);
const to = extractHeader(raw, 'To');
const cc = extractHeader(raw, 'Cc') || null;
const subject = extractHeader(raw, 'Subject') || '(no subject)';
const dateStr = extractHeader(raw, 'Date');
const date = dateStr ? new Date(dateStr).toISOString() : new Date(0).toISOString();
const snippet = extractSnippet(raw);
const attachments = parseAttachments(raw);
const domain = extractDomain(address);
const { html, text } = extractBody(raw);
const threadId = computeThreadId(db, id, raw);
db.run(upsertEmailStmt, [
id,
integration,
emailAccount,
name,
address,
domain,
to,
cc,
subject,
date,
snippet,
html,
text,
attachments.length,
labelsToString(labels),
threadId,
]);
if (attachments.length > 0) {
db.run(deleteAttachmentsStmt, [id]);
for (let i = 0; i < attachments.length; i++) {
const att = attachments[i]!;
db.run(insertAttachmentStmt, [id, i, att.filename, att.size, att.contentType, att.content]);
}
}
syncFtsRow(db, id, subject, `${name} ${address}`.trim(), `${to} ${cc ?? ''}`.trim(), snippet, text ?? '');
}
/** Convert a db row to an EmailSummary for the API */
export function rowToSummary(row: Record<string, unknown>): EmailSummary {
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
const labels = labelsFromString(row.labels);
return {
id: row.id as string,
from,
to: row.to_address as string,
subject: row.subject as string,
date: row.date as string,
snippet: row.snippet as string,
...(row.attachment_count ? { attachmentCount: row.attachment_count as number } : {}),
...(row.read ? { read: true } : {}),
...(labels ? { labels } : {}),
...(row.thread_count && (row.thread_count as number) > 1 ? { threadCount: row.thread_count as number } : {}),
...(row.thread_unread ? { threadUnread: row.thread_unread as number } : {}),
};
}
// ── Sync meta helpers ──
export function getSyncMeta(db: Database, key: string): string | null {
const row = db.query('SELECT value FROM sync_meta WHERE key = ?').get(key) as { value: string } | null;
return row?.value ?? null;
}
export function setSyncMeta(db: Database, key: string, value: string): void {
db.run('INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)', [key, value]);
}
export function updateEmailLabels(db: Database, id: string, labels: string[]): void {
db.run('UPDATE emails SET labels = ? WHERE id = ?', [labelsToString(labels), id]);
}
// ── Header parsing helpers (same logic as gmail-sync) ──
function decodeMimeWords(text: string): string {
return text.replace(/=\?([^?]+)\?(B|Q)\?([^?]+)\?=/gi, (_, charset, encoding, encoded) => {
try {
const normalizedCs = normalizeCharset(charset.toLowerCase());
if (encoding.toUpperCase() === 'B') {
const buf = Buffer.from(encoded, 'base64');
return new TextDecoder(normalizedCs, { fatal: false }).decode(buf);
}
const bytes: number[] = [];
for (let i = 0; i < encoded.length; i++) {
if (encoded[i] === '_') {
bytes.push(0x20);
} else if (encoded[i] === '=' && i + 2 < encoded.length) {
bytes.push(parseInt(encoded.slice(i + 1, i + 3), 16));
i += 2;
} else {
bytes.push(encoded.charCodeAt(i));
}
}
return new TextDecoder(normalizedCs, { fatal: false }).decode(Buffer.from(bytes));
} catch {
return encoded;
}
});
}
function extractHeader(raw: string, name: string): string {
const match = raw.match(new RegExp(`^${name}:\\s*(.+)$`, 'mi'));
return match?.[1]?.trim() ? decodeMimeWords(match[1].trim()) : '';
}
/** Extract a header value including folded continuation lines (lines starting with whitespace) */
function extractFullHeader(raw: string, name: string): string {
const headerEnd = findHeaderEnd(raw);
const headerBlock = headerEnd !== -1 ? raw.slice(0, headerEnd) : raw.slice(0, 4096);
const lines = headerBlock.split(/\r?\n/);
let result = '';
let capturing = false;
for (const line of lines) {
if (new RegExp(`^${name}:\\s*`, 'i').test(line)) {
result = line.replace(new RegExp(`^${name}:\\s*`, 'i'), '');
capturing = true;
} else if (capturing && /^[\t ]/.test(line)) {
result += ' ' + line.trim();
} else if (capturing) {
break;
}
}
return result.trim();
}
function findHeaderEnd(text: string): number {
const crlf = text.indexOf('\r\n\r\n');
const lf = text.indexOf('\n\n');
if (crlf !== -1) return crlf + 4;
if (lf !== -1) return lf + 2;
return -1;
}
function extractSnippet(raw: string): string {
const idx = findHeaderEnd(raw);
if (idx === -1) return '';
let body = raw.slice(idx);
if (body.trimStart().startsWith('--')) {
const afterBoundary = body.slice(body.indexOf('\n') + 1);
const partBodyStart = findHeaderEnd(afterBoundary);
if (partBodyStart !== -1) body = afterBoundary.slice(partBodyStart);
}
const nextBoundary = body.indexOf('\n--');
if (nextBoundary !== -1) body = body.slice(0, nextBoundary);
return body.replace(/\s+/g, ' ').trim().slice(0, 120);
}
function decodeQuotedPrintableBytes(text: string): Buffer {
const cleaned = text.replace(/=\r?\n/g, '');
const bytes: number[] = [];
for (let i = 0; i < cleaned.length; i++) {
if (cleaned[i] === '=' && i + 2 < cleaned.length) {
const hex = cleaned.slice(i + 1, i + 3);
const val = parseInt(hex, 16);
if (!isNaN(val)) {
bytes.push(val);
i += 2;
continue;
}
}
bytes.push(cleaned.charCodeAt(i));
}
return Buffer.from(bytes);
}
function extractCharset(contentType: string): string {
const match = contentType.match(/charset=["']?([^"';\s]+)/i);
return match?.[1]?.toLowerCase() ?? 'utf-8';
}
function normalizeCharset(charset: string): string {
const map: Record<string, string> = {
'iso-8859-1': 'latin1',
'iso_8859-1': 'latin1',
'iso-8859-15': 'latin1',
'iso_8859-15': 'latin1',
'windows-1250': 'latin1',
'windows-1251': 'latin1',
'windows-1252': 'latin1',
'windows-1254': 'latin1',
'us-ascii': 'ascii',
ascii: 'ascii',
};
return map[charset] ?? charset;
}
function decodePartBody(body: string, encoding: string, charset = 'utf-8'): string {
const enc = encoding.toLowerCase();
const normalizedCharset = normalizeCharset(charset);
if (enc === 'base64') {
const buf = Buffer.from(body.replace(/\s/g, ''), 'base64');
return new TextDecoder(normalizedCharset, { fatal: false }).decode(buf);
}
if (enc === 'quoted-printable') {
const buf = decodeQuotedPrintableBytes(body);
return new TextDecoder(normalizedCharset, { fatal: false }).decode(buf);
}
return body;
}
function extractBody(raw: string): { html: string | null; text: string | null } {
const headerEnd = findHeaderEnd(raw);
if (headerEnd === -1) return { html: null, text: null };
const topCtRaw = extractFullHeader(raw, 'Content-Type');
const topCt = topCtRaw.toLowerCase();
const topEncoding = extractFullHeader(raw, 'Content-Transfer-Encoding');
// Non-multipart: single body
if (!topCt.includes('multipart')) {
const body = raw.slice(headerEnd);
const charset = extractCharset(topCtRaw);
const decoded = decodePartBody(body, topEncoding, charset);
if (topCt.includes('text/html')) return { html: decoded, text: null };
return { html: null, text: decoded };
}
// Multipart: extract boundary from the raw (case-sensitive) header
const boundaryMatch = topCtRaw.match(/boundary=["']?([^"';\s]+)/i);
if (!boundaryMatch) return { html: null, text: null };
const boundary = boundaryMatch[1]!;
let html: string | null = null;
let text: string | null = null;
const parts = raw.slice(headerEnd).split(`--${boundary}`);
for (const part of parts) {
if (part.startsWith('--') || !part.trim()) continue;
const partHeaderEnd = findHeaderEnd(part);
if (partHeaderEnd === -1) continue;
const partCtRaw = extractFullHeader(part, 'Content-Type');
const partCt = partCtRaw.toLowerCase();
const partEnc = extractFullHeader(part, 'Content-Transfer-Encoding');
const partCharset = extractCharset(partCtRaw);
const partBody = part.slice(partHeaderEnd);
// Recurse into nested multipart (e.g. multipart/alternative inside multipart/mixed)
if (partCt.includes('multipart')) {
const nested = extractBody(part.trim());
if (nested.html && !html) html = nested.html;
if (nested.text && !text) text = nested.text;
continue;
}
if (partCt.includes('text/html') && !html) {
html = decodePartBody(partBody, partEnc, partCharset);
} else if (partCt.includes('text/plain') && !text) {
text = decodePartBody(partBody, partEnc, partCharset);
}
}
return { html, text };
}
type AttachmentMeta = { filename: string; size: number; contentType: string; content: string };
function parseAttachments(raw: string): AttachmentMeta[] {
const results: AttachmentMeta[] = [];
// Match both "attachment" and "inline" dispositions
const regex = /^Content-Disposition:\s*(?:attachment|inline)[^\n]*/gim;
let match: RegExpExecArray | null;
while ((match = regex.exec(raw)) !== null) {
const pos = match.index;
// Walk backwards to find the start of this MIME part's headers
const partStart = raw.lastIndexOf('\n--', pos);
const headerBlock =
partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500);
// Skip inline parts without a filename (e.g. inline text/plain body parts)
const hasFilename = /filename/i.test(headerBlock);
if (!hasFilename) continue;
// Extract filename from Content-Disposition or Content-Type
const fnMatch = headerBlock.match(/filename\*?=(?:"([^"]+)"|([^\s;]+))/i);
let rawFilename = fnMatch ? (fnMatch[1] ?? fnMatch[2] ?? 'unknown') : 'unknown';
// RFC 5987: filename*=charset''percent-encoded
const rfc5987Match = rawFilename.match(/^([^']*)'[^']*'(.+)/);
if (rfc5987Match) {
const cs = normalizeCharset(rfc5987Match[1]!.toLowerCase() || 'utf-8');
const encoded = rfc5987Match[2]!;
const bytes = encoded.replace(/%([0-9A-Fa-f]{2})/g, (_, h: string) => String.fromCharCode(parseInt(h, 16)));
rawFilename = new TextDecoder(cs, { fatal: false }).decode(Buffer.from(bytes, 'binary'));
}
const filename = decodeMimeWords(rawFilename);
// Extract content-type
const ctMatch = headerBlock.match(/^Content-Type:\s*([^\s;]+)/im);
const contentType = ctMatch?.[1] ?? 'application/octet-stream';
// Extract full body content as base64
const partHeaderEnd = findHeaderEnd(raw.slice(pos));
let content = '';
let size = 0;
if (partHeaderEnd !== -1) {
const bodyStart = pos + partHeaderEnd;
const boundaryEnd = raw.indexOf('\n--', bodyStart);
const bodyRaw = boundaryEnd !== -1 ? raw.slice(bodyStart, boundaryEnd) : raw.slice(bodyStart);
// Detect encoding from part headers
const encMatch = headerBlock.match(/^Content-Transfer-Encoding:\s*(\S+)/im);
const encoding = encMatch?.[1]?.toLowerCase() ?? 'base64';
if (encoding === 'base64') {
content = bodyRaw.replace(/\s/g, '');
} else {
// For quoted-printable or 7bit/8bit, re-encode to base64
const buf = encoding === 'quoted-printable' ? decodeQuotedPrintableBytes(bodyRaw) : Buffer.from(bodyRaw);
content = buf.toString('base64');
}
size = Math.floor((content.length * 3) / 4);
}
results.push({ filename, size, contentType, content });
}
return results;
}
-124
View File
@@ -1,124 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { Database } from 'bun:sqlite';
import { readSyncMeta, writeSyncMeta, hasSyncedBefore } from './sync-meta';
// Moving an account's sync position out of Postgres and into its own emails.db. Both failure modes are
// expensive and neither is loud, so they are pinned here:
//
// migrate when we should not -> a restored older mailbox inherits a newer position and SKIPS mail
// fail to migrate -> an existing account looks brand new and re-downloads everything
const freshDb = () => {
const db = new Database(':memory:');
db.exec('CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT)');
return db;
};
describe('readSyncMeta', () => {
it('reads what the file already knows', () => {
const db = freshDb();
writeSyncMeta(db, { last_sync_at: '2026-01-01T00:00:00Z', 'imap_lastuid:INBOX': '4200' });
expect(readSyncMeta(db)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_lastuid:INBOX': '4200' });
});
it('migrates keys the file is MISSING, even when it already has others', () => {
// The case that broke a live 6.9 GB mailbox. The older Gmail backfill wrote last_sync_at and the
// uidvalidity set into SQLite and never the imap_lastuid ones, so the file was non-empty and
// half-migrated at once. All-or-nothing skipped it, leaving no lastuid for any folder — and a
// missing lastuid refetches that folder from UID 1.
const db = freshDb();
writeSyncMeta(db, { last_sync_at: '2026-07-23T00:00:00Z', 'imap_uidvalidity:INBOX': '2' });
const meta = readSyncMeta(db, {
last_sync_at: '2026-08-10T00:00:00Z', // newer, but the file's own answer stands
'imap_uidvalidity:INBOX': '2',
'imap_lastuid:INBOX': '4200', // absent from the file — this is what must arrive
});
expect(meta['imap_lastuid:INBOX']).toBe('4200');
expect(meta.last_sync_at).toBe('2026-07-23T00:00:00Z');
// Persisted, so the fallback is not needed next time.
expect(readSyncMeta(db)['imap_lastuid:INBOX']).toBe('4200');
});
it('migrates from Postgres exactly once, when the file has nothing', () => {
// The account existed before the move. Without this it looks like a first sync and re-downloads
// the entire mailbox.
const db = freshDb();
const pg = { last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' };
expect(readSyncMeta(db, pg)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' });
// Backfilled, so the fallback is never consulted again.
expect(readSyncMeta(db)).toEqual({ last_sync_at: '2026-01-01T00:00:00Z', 'imap_uidvalidity:INBOX': '99' });
});
it('NEVER lets Postgres override a position the file already has', () => {
// The dangerous direction. Restore an older emails.db onto a server whose Postgres row is newer,
// and topping up from Postgres would advance past mail this file does not contain — skipped
// permanently, because nothing looks below lastuid again. The file wins, always.
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': '100' });
const meta = readSyncMeta(db, { 'imap_lastuid:INBOX': '9999' });
// The key the file has is never overwritten — that is what stops a restored older mailbox being
// advanced past mail it does not contain.
expect(meta['imap_lastuid:INBOX']).toBe('100');
});
it('ignores non-string values rather than stringifying them', () => {
// The Postgres column is free-form jsonb. An object coerced to "[object Object]" would be a
// position that parses as a number nowhere and compares as garbage.
const db = freshDb();
expect(readSyncMeta(db, { good: 'yes', bad: { nested: true }, alsoBad: 42 } as never)).toEqual({ good: 'yes' });
});
it('is empty for a genuinely new account', () => {
expect(readSyncMeta(freshDb(), {})).toEqual({});
expect(readSyncMeta(freshDb(), null)).toEqual({});
});
});
describe('writeSyncMeta', () => {
it('upserts rather than duplicating, so a checkpoint mid-sync is safe to repeat', () => {
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': '10' });
writeSyncMeta(db, { 'imap_lastuid:INBOX': '20' });
expect(readSyncMeta(db)).toEqual({ 'imap_lastuid:INBOX': '20' });
});
it('skips null and undefined instead of writing them as text', () => {
// "null" as a stored lastuid would parse to NaN and compare false against every real UID.
const db = freshDb();
writeSyncMeta(db, { a: '1', b: null, c: undefined });
expect(readSyncMeta(db)).toEqual({ a: '1' });
});
it('stores numbers as their text form, since UIDs arrive both ways', () => {
const db = freshDb();
writeSyncMeta(db, { 'imap_lastuid:INBOX': 4200 });
expect(readSyncMeta(db)['imap_lastuid:INBOX']).toBe('4200');
});
});
describe('hasSyncedBefore', () => {
it('is false for a new account and true once the file records a sync', () => {
const db = freshDb();
expect(hasSyncedBefore(db)).toBe(false);
writeSyncMeta(db, { last_sync_at: '2026-01-01T00:00:00Z' });
expect(hasSyncedBefore(db)).toBe(true);
});
it('is true for an account that synced before the move but has not migrated yet', () => {
// The whole point: an unmigrated account must not be treated as new. This is the check that stands
// between an existing user and an unnecessary full re-sync of their mailbox.
expect(hasSyncedBefore(freshDb(), { last_sync_at: '2026-01-01T00:00:00Z' })).toBe(true);
});
it('is false when Postgres holds a position but no completed sync', () => {
// Folder UIDs without last_sync_at means a sync started and never finished.
expect(hasSyncedBefore(freshDb(), { 'imap_uidvalidity:INBOX': '99' })).toBe(false);
});
});
-94
View File
@@ -1,94 +0,0 @@
import type { Database } from 'bun:sqlite';
import { getSyncMeta, setSyncMeta } from './store';
// Where an account's sync position lives: IN ITS OWN emails.db, not in Postgres.
//
// ── Why this moved ──
//
// The messages are in `emails.db` and the position — `last_sync_at`, and per-folder `uidvalidity` +
// `lastuid` — used to be a jsonb column on `email_accounts` in Postgres. Two stores for one fact, and
// the split had a sharp edge that only appears when you try to move a mailbox to another machine.
//
// The expensive thing about an email account is the initial sync: hours of IMAP for a large mailbox. So
// "copy emails.db to the new server" is the obvious way to bring an account across. With the position in
// Postgres that silently does not work — the new server's `sync_meta` is empty, `!last_sync_at` says
// first sync, and it downloads the whole mailbox again on top of the one you just restored.
//
// The worse direction is quieter. Restore an OLDER emails.db while Postgres still holds a NEWER
// position, and the sidecar skips every message between the two — permanently, because nothing ever
// looks below `lastuid` again. Re-syncing is slow; skipping mail is data loss you do not notice.
//
// Keeping both in one file makes the file self-describing: it says what it holds and where it got to,
// and the two cannot disagree because they travel together.
//
// ── Why this is a generalisation, not a new idea ──
//
// The Gmail path already did exactly this (`gmail-api.ts`): read SQLite, fall back to the Postgres
// column, backfill SQLite so the fallback is only ever taken once. That was the right shape and only
// the IMAP path had not followed. This is that pattern, extracted so both use one copy of it.
//
// ── The Postgres column stays ──
//
// `email_accounts.sync_meta` is not dropped. It is the backfill source for every account that exists
// today, and it costs nothing to leave. It is no longer written to; see `readSyncMeta` for the one-way
// migration, which happens the first time an account syncs after this change.
/**
* The whole sync position for this account, from SQLite — migrating it out of Postgres once if this is
* the first read since the change.
*
* `pgFallback` is the Postgres jsonb as it stands. Pass it; the migration cannot happen without it, and
* an account whose position never migrates re-syncs its entire mailbox.
*/
export function readSyncMeta(db: Database, pgFallback?: Record<string, unknown> | null): Record<string, string> {
const rows = db.query('SELECT key, value FROM sync_meta').all() as Array<{ key: string; value: string }>;
const meta: Record<string, string> = {};
for (const row of rows) meta[row.key] = row.value;
// Merged PER KEY, and the file always wins a conflict.
//
// This started life as all-or-nothing — migrate only when SQLite is completely empty — on the
// assumption that a non-empty file is an authoritative one. A real account disproved it immediately.
// The older Gmail backfill wrote SOME keys into SQLite (`last_sync_at`, the `uidvalidity` set) and
// never the `imap_lastuid:*` ones, so the file was non-empty and half-migrated at the same time.
// All-or-nothing therefore skipped the migration, leaving no lastuid for any folder — and a missing
// lastuid means the next sync refetches that folder from UID 1. On the mailbox this was found on,
// that was 18,755 messages and 6.9 GB.
//
// Per-key with file-wins keeps the property that made all-or-nothing attractive: a restored older
// emails.db still overrides a newer Postgres row for every key it actually has, so it cannot be
// advanced past mail it does not contain. What it adds is the keys the file never had, which are
// exactly the ones whose absence is expensive.
if (pgFallback) {
for (const [key, value] of Object.entries(pgFallback)) {
if (typeof value !== 'string') continue;
if (key in meta) continue; // the file's own answer stands
meta[key] = value;
setSyncMeta(db, key, value);
}
}
return meta;
}
/**
* Persist the position. SQLite only — Postgres is no longer written.
*
* Called after the messages are already stored, which keeps the safe ordering the previous code had: if
* this never runs, the position stays behind and those messages are fetched again. Re-fetching is
* wasteful; advancing past mail that was never stored would lose it.
*/
export function writeSyncMeta(db: Database, meta: Record<string, unknown>): void {
for (const [key, value] of Object.entries(meta)) {
if (value === undefined || value === null) continue;
setSyncMeta(db, key, String(value));
}
}
/** Has this account ever completed a sync? The question `isFirstSync` is really asking. */
export function hasSyncedBefore(db: Database, pgFallback?: Record<string, unknown> | null): boolean {
if (getSyncMeta(db, 'last_sync_at')) return true;
// Not yet migrated: an account that synced before this change has its answer in Postgres, and
// treating it as a first sync would re-download the whole mailbox.
const pgValue = pgFallback?.last_sync_at;
return typeof pgValue === 'string' && pgValue.length > 0;
}
-94
View File
@@ -1,94 +0,0 @@
// Sync scheduling, owned by the sidecar.
//
// These syncs used to be platform queue jobs, which gave them retries and a row in the Jobs list. That was
// the wrong home twice over: the credentials travelled through Postgres job metadata to get there, and a
// mailbox sync has nothing to do with what the Jobs screen is for. They run here now, tracked in memory,
// and the Jobs list is left to the things it actually describes.
//
// Deliberately NOT a queue: one run per account at a time, no persistence, no retry. A failed sync is
// retried by the ten-minute cron like any other, and a sync interrupted by a restart resumes from the
// stored sync cursor rather than from the beginning.
/** What a sync reports as it goes. `total: 0` means "unknown" — IMAP does not tell us up front. */
export type SyncProgress = { current: number; total: number; label: string };
export type SyncState = {
accountId: number;
status: 'running' | 'completed' | 'failed';
progress: SyncProgress | null;
startedAt: number;
finishedAt?: number;
error?: string;
};
const states = new Map<number, SyncState>();
/** The steps of a converted job handler: each is run in order with a synthesized context. */
export type SyncStep = { name: string; run: (ctx: SyncCtx) => Promise<void> | void };
/**
* What the moved handlers expect on their context. `job.userId` is the OWNER'S EMAIL, not a numeric id —
* the queue called it userId and the handlers use it to resolve the mail store path
* (DATA_PATH/<owner>/email_accounts/…). Getting this wrong reads as an empty mailbox rather than an error.
*/
export type SyncCtx = {
/** Same shape the queue's StepContext used, so the moved handlers read it unchanged. */
meta: Record<string, unknown>;
job: { userId: string };
updateProgress: (p: SyncProgress) => Promise<void> | void;
};
export function isSyncing(accountId: number): boolean {
return states.get(accountId)?.status === 'running';
}
export function getSyncStates(): SyncState[] {
return [...states.values()];
}
/**
* Run `steps` in the background for one account. Returns immediately — an initial mailbox sync takes
* minutes to hours, so nothing waits on it. Re-entrant calls for an account already syncing are ignored.
*/
export function startSync(params: {
accountId: number;
ownerEmail: string;
meta: Record<string, unknown>;
steps: SyncStep[];
onDone?: (ok: boolean) => Promise<void> | void;
}): SyncState {
const existing = states.get(params.accountId);
if (existing?.status === 'running') return existing;
const state: SyncState = {
accountId: params.accountId,
status: 'running',
progress: null,
startedAt: Date.now(),
};
states.set(params.accountId, state);
const ctx: SyncCtx = {
meta: params.meta,
job: { userId: params.ownerEmail },
updateProgress: (p) => {
state.progress = p;
},
};
void (async () => {
try {
for (const step of params.steps) await step.run(ctx);
state.status = 'completed';
} catch (err) {
state.status = 'failed';
state.error = err instanceof Error ? err.message : String(err);
console.error(`[email-sync] account ${params.accountId} failed:`, state.error);
} finally {
state.finishedAt = Date.now();
await params.onDone?.(state.status === 'completed');
}
})();
return state;
}
-29
View File
@@ -1,29 +0,0 @@
export type EmailSummary = {
id: string;
from: string;
to: string;
subject: string;
date: string;
snippet: string;
attachmentCount?: number;
read?: boolean;
fromDomain?: string;
labels?: string[];
// Conversation grouping: total messages in the thread, and how many are unread (folder view only).
threadCount?: number;
threadUnread?: number;
};
export type EmailMessage = EmailSummary & {
cc?: string;
html?: string;
text?: string;
attachments: Array<{ filename: string; size: number; contentType: string }>;
};
// A full conversation: every non-deleted message in the thread, oldest first.
export type EmailThread = {
id: string; // the representative message id used to open the thread
subject: string;
messages: EmailMessage[];
};
-1
View File
@@ -2,7 +2,6 @@ export type {} from './globals';
export * from 'officerdb/types';
export type { Job, JobStep, JobStatus, JobStepStatus, JobProgress } from './queue';
export type { EmailSummary, EmailMessage, EmailThread } from './email';
export type SelectOption = { value: number | string; label?: string };