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,258 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { BAD_REQUEST, NOT_FOUND } from '@@/custom-errors';
|
||||
import { getEmailAccounts, getEmailAccount, createEmailAccount, deleteEmailAccount, updateEmailAccountStatus } from '../db/queries';
|
||||
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}` };
|
||||
}
|
||||
Reference in New Issue
Block a user