email, extracted from the officer platform
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.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user