IMAP and Gmail sync into a per-account SQLite store, with the mail read and composed in the browser. moved whole from src/servers/sidecar/email (14 files), src/servers/api/email, officer_db/src/email, and the Screens/Dashboard/Email tree. what changed on the way out: - credentials are encrypted. the column was plaintext jsonb holding an IMAP app password that the SMTP transport read straight out of the row; it is now AES-256-GCM under this plugin's own 'email' secret-store purpose, sealed and opened in queries.ts alone so nothing above it can hold ciphertext by accident. no plaintext fallback on read - a value that does not verify is an error. - the router derives its prefix from mountPrefix() instead of the literal '/api/email', which was only correct for a first-party publisher. - the frontend became panels.ts + layout.ts. the two panel appTypes that were null are now declared, because the platform screen used to override them by panel id and a plugin has no such hook. - account management became a modal. it was a section in the platform's Settings, which a plugin cannot contribute to, and a panel does not work either: PluginScreen renders the workspace locked, so a panel outside the default layout is unreachable. it follows Compose, which had already solved this. - 17 palette classes replaced with semantic tokens. not verified: this has never run. it was switched off in the platform on 2026-08-13 and its table was never created, so nothing has exercised sync, sending or the UI since. the first install is the first test.
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
// 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;
|
|
}
|