email: push new-mail to /email via SSE (IMAP IDLE -> sidecar -> server -> EventSource)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 17:29:20 +00:00
co-authored by Claude Opus 4.8
parent 5a0b9ce70f
commit 4b08d0b99c
6 changed files with 93 additions and 4 deletions
+54
View File
@@ -10,6 +10,60 @@ export const emailRouter = createRouter();
emailRouter.route('/accounts', accountsRouter);
// ── 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' },
});
});
emailRouter.get('/messages', async (ctx) => {
const email = ctx.get('user').email;
const page = Number(ctx.req.query('page') ?? '1');
+7 -2
View File
@@ -25,6 +25,7 @@ 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') {
@@ -46,7 +47,10 @@ async function runSync(w: Watcher): Promise<void> {
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`);
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 {
@@ -141,8 +145,9 @@ async function reconcile(): Promise<void> {
}
}
export function initEmailIdle(): void {
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);
+3 -2
View File
@@ -85,8 +85,9 @@ const connection = createSidecarConnector({
onConnected() {
// 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.
initEmailIdle();
// 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.
initEmailIdle((userEmail) => connection.send({ type: 'email:new', userEmail }));
},
});
+2
View File
@@ -50,6 +50,8 @@ export type SidecarEvent =
| { type: 'vnc:stopped'; id: string }
| { type: 'vnc:status'; id: string; session: VncSessionInfo | null }
| { type: 'vnc:error'; id: string; error: string }
// Email
| { type: 'email:new'; userEmail: string }
// Generic
| { type: 'error'; id?: string; error: string };