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.
259 lines
8.2 KiB
TypeScript
259 lines
8.2 KiB
TypeScript
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}` };
|
|
}
|