workspaces to dashboards, imap email sync, ffmpeg tool, tts fix, file browser refresh, automation sidebar reorder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { sign, verify } from '@@/jwt';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePassword } from './validate-password';
|
||||
import { validateUsername } from './validate-username';
|
||||
|
||||
export const bootstrapHandler: Handler = async function (ctx) {
|
||||
const body = ctx.get('body');
|
||||
@@ -42,7 +43,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
||||
const confirmPassword = body.confirmPassword as string;
|
||||
|
||||
if (!name || !name.trim()) throw errors.BAD_REQUEST('Name is required');
|
||||
if (!username || !username.trim()) throw errors.BAD_REQUEST('Username is required');
|
||||
const validUsername = validateUsername(username);
|
||||
validatePassword(password);
|
||||
if (password !== confirmPassword) throw errors.BAD_REQUEST('Passwords do not match');
|
||||
|
||||
@@ -52,7 +53,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
||||
email: payload.email,
|
||||
password: passwordHash,
|
||||
name: name.trim(),
|
||||
username: username.trim(),
|
||||
username: validUsername,
|
||||
role: 'Super Admin',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as errors from '@@/custom-errors';
|
||||
|
||||
export function validateUsername(username: string | undefined): string {
|
||||
if (!username || !username.trim()) {
|
||||
throw errors.BAD_REQUEST('Username is required');
|
||||
}
|
||||
|
||||
const trimmed = username.trim();
|
||||
|
||||
if (trimmed.includes('@')) {
|
||||
throw errors.BAD_REQUEST('Username cannot be an email address');
|
||||
}
|
||||
|
||||
if (trimmed.length < 2 || trimmed.length > 32) {
|
||||
throw errors.BAD_REQUEST('Username must be between 2 and 32 characters');
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
|
||||
throw errors.BAD_REQUEST('Username can only contain letters, numbers, dots, hyphens, and underscores');
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { verify as verifyJwt, sign } from '@@/jwt';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePassword } from './validate-password';
|
||||
import { validateUsername } from './validate-username';
|
||||
|
||||
export const verifyHandler: Handler = async function (ctx) {
|
||||
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
|
||||
@@ -24,7 +25,7 @@ export const verifyHandler: Handler = async function (ctx) {
|
||||
}
|
||||
|
||||
if (username && typeof username === 'string' && username.trim()) {
|
||||
updates.username = username.trim();
|
||||
updates.username = validateUsername(username);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
|
||||
+13
-10
@@ -2,35 +2,38 @@ import { mkdir, readdir, rm, cp } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils';
|
||||
import { getDirs, migrateDashboardsDir, migrateFromState, migrateHomepageToScreens, readAllDashboardsState, resolveKey, writeJsonFile } from './utils';
|
||||
|
||||
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
|
||||
|
||||
export const workspacesRouter = createRouter();
|
||||
export const dashboardsRouter = createRouter();
|
||||
|
||||
// GET /workspaces
|
||||
workspacesRouter.get('/', async (ctx) => {
|
||||
// GET /dashboards
|
||||
dashboardsRouter.get('/', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
|
||||
await migrateDashboardsDir(email);
|
||||
|
||||
const dirs = getDirs(email);
|
||||
|
||||
const dirFile = Bun.file(join(dirs.wsDir, 'index.json'));
|
||||
const dirFile = Bun.file(join(dirs.dashDir, 'index.json'));
|
||||
if (!(await dirFile.exists())) {
|
||||
await migrateFromState(email, dirs);
|
||||
}
|
||||
|
||||
await migrateHomepageToScreens(dirs, email);
|
||||
|
||||
const state = await readAllWorkspacesState(dirs);
|
||||
const state = await readAllDashboardsState(dirs);
|
||||
return ctx.json(state);
|
||||
});
|
||||
|
||||
// PATCH /workspaces
|
||||
workspacesRouter.patch('/', async (ctx) => {
|
||||
// PATCH /dashboards
|
||||
dashboardsRouter.patch('/', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const dirs = getDirs(email);
|
||||
|
||||
await mkdir(dirs.wsDir, { recursive: true });
|
||||
await mkdir(dirs.dashDir, { recursive: true });
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
// Handle proj-meta-{slug}: create/update/delete project
|
||||
@@ -94,6 +97,6 @@ workspacesRouter.patch('/', async (ctx) => {
|
||||
await writeJsonFile(mapping.file, value);
|
||||
}
|
||||
|
||||
const state = await readAllWorkspacesState(dirs);
|
||||
const state = await readAllDashboardsState(dirs);
|
||||
return ctx.json(state);
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './workspaces';
|
||||
export * from './dashboards';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,3 @@
|
||||
export type KeyMapping = { file: string; dir?: string };
|
||||
|
||||
export type ResolveDirs = { dashDir: string; screensDir: string; projDir: string };
|
||||
@@ -1,14 +1,14 @@
|
||||
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile, getUserProjectsDir } from '@@/data-path';
|
||||
import { getUserDashboardsDir, getUserHomepageDashboardDir, getUserStateFile, getUserProjectsDir, DATA_PATH } from '@@/data-path';
|
||||
import type { KeyMapping, ResolveDirs } from './types';
|
||||
|
||||
const RESERVED_DIRS = new Set(['screens']);
|
||||
|
||||
export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
||||
if (key === 'workspaces') return { file: join(dirs.wsDir, 'index.json') };
|
||||
if (key === 'ws-terminals-default') return { file: join(dirs.wsDir, 'default-terminals.json') };
|
||||
if (key === 'ws-host-terminals-default') return { file: join(dirs.wsDir, 'default-host-terminals.json') };
|
||||
if (key === 'workspaces') return { file: join(dirs.dashDir, 'index.json') };
|
||||
if (key === 'ws-terminals-default') return { file: join(dirs.dashDir, 'default-terminals.json') };
|
||||
if (key === 'ws-host-terminals-default') return { file: join(dirs.dashDir, 'default-host-terminals.json') };
|
||||
|
||||
// screens/{name} → screens/{name}/layout.json
|
||||
const screensMatch = key.match(/^screens\/(.+)$/);
|
||||
@@ -21,21 +21,21 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
||||
const layoutMatch = key.match(/^ws-layout-(.+)$/);
|
||||
if (layoutMatch) {
|
||||
const id = layoutMatch[1]!;
|
||||
const dir = join(dirs.wsDir, id);
|
||||
const dir = join(dirs.dashDir, id);
|
||||
return { file: join(dir, 'layout.json'), dir };
|
||||
}
|
||||
|
||||
const terminalsMatch = key.match(/^ws-terminals-(.+)$/);
|
||||
if (terminalsMatch) {
|
||||
const id = terminalsMatch[1]!;
|
||||
const dir = join(dirs.wsDir, id);
|
||||
const dir = join(dirs.dashDir, id);
|
||||
return { file: join(dir, 'terminals.json'), dir };
|
||||
}
|
||||
|
||||
const hostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/);
|
||||
if (hostTerminalsMatch) {
|
||||
const id = hostTerminalsMatch[1]!;
|
||||
const dir = join(dirs.wsDir, id);
|
||||
const dir = join(dirs.dashDir, id);
|
||||
return { file: join(dir, 'host-terminals.json'), dir };
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
|
||||
);
|
||||
if (wsKeys.length === 0) return;
|
||||
|
||||
await mkdir(dirs.wsDir, { recursive: true });
|
||||
await mkdir(dirs.dashDir, { recursive: true });
|
||||
|
||||
for (const key of wsKeys) {
|
||||
const migratedKey = key === 'ws-layout-workspaces' ? 'screens/homepage' : key;
|
||||
@@ -113,8 +113,7 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
|
||||
}
|
||||
|
||||
export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) {
|
||||
// Migrate from old ws-homepage/ dir to workspaces/screens/homepage/
|
||||
const oldHomepageDir = getUserHomepageWorkspaceDir(email);
|
||||
const oldHomepageDir = getUserHomepageDashboardDir(email);
|
||||
const layoutFile = join(oldHomepageDir, 'layout.json');
|
||||
if (!(await Bun.file(layoutFile).exists())) return;
|
||||
|
||||
@@ -131,8 +130,7 @@ export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string)
|
||||
const remaining = await readdir(oldHomepageDir);
|
||||
if (remaining.length === 0) await rm(oldHomepageDir, { recursive: true, force: true });
|
||||
|
||||
// Also migrate from workspaces/ws-homepage/ if it exists
|
||||
const oldWsHomepageDir = join(dirs.wsDir, 'ws-homepage');
|
||||
const oldWsHomepageDir = join(dirs.dashDir, 'ws-homepage');
|
||||
const oldWsLayout = join(oldWsHomepageDir, 'layout.json');
|
||||
if (!(await Bun.file(oldWsLayout).exists())) return;
|
||||
|
||||
@@ -148,7 +146,24 @@ export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string)
|
||||
if (wsRemaining.length === 0) await rm(oldWsHomepageDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function readWorkspaceDir(dirPath: string, id: string, result: Record<string, unknown>) {
|
||||
// Migrate on-disk directory from old 'workspaces' name to 'dashboards'
|
||||
export async function migrateDashboardsDir(email: string) {
|
||||
const oldDir = join(DATA_PATH, email, 'workspaces');
|
||||
const newDir = getUserDashboardsDir(email);
|
||||
try {
|
||||
const oldExists = await Bun.file(join(oldDir, 'index.json')).exists() || await readdir(oldDir).then(() => true).catch(() => false);
|
||||
if (oldExists) {
|
||||
const newExists = await readdir(newDir).then(() => true).catch(() => false);
|
||||
if (!newExists) {
|
||||
await rename(oldDir, newDir);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — old dir doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
async function readDashboardDir(dirPath: string, id: string, result: Record<string, unknown>) {
|
||||
const layout = await readJsonFile(join(dirPath, 'layout.json'));
|
||||
if (layout !== null) result[`ws-layout-${id}`] = layout;
|
||||
|
||||
@@ -191,32 +206,32 @@ async function readScreensDir(screensDir: string, result: Record<string, unknown
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
|
||||
export async function readAllDashboardsState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
const indexData = await readJsonFile(join(dirs.wsDir, 'index.json'));
|
||||
const indexData = await readJsonFile(join(dirs.dashDir, 'index.json'));
|
||||
if (indexData !== null) result['workspaces'] = indexData;
|
||||
|
||||
const defaultTerminals = await readJsonFile(join(dirs.wsDir, 'default-terminals.json'));
|
||||
const defaultTerminals = await readJsonFile(join(dirs.dashDir, 'default-terminals.json'));
|
||||
if (defaultTerminals !== null) result['ws-terminals-default'] = defaultTerminals;
|
||||
|
||||
const defaultHostTerminals = await readJsonFile(join(dirs.wsDir, 'default-host-terminals.json'));
|
||||
const defaultHostTerminals = await readJsonFile(join(dirs.dashDir, 'default-host-terminals.json'));
|
||||
if (defaultHostTerminals !== null) result['ws-host-terminals-default'] = defaultHostTerminals;
|
||||
|
||||
// Read screens
|
||||
await readScreensDir(dirs.screensDir, result);
|
||||
|
||||
// Read per-workspace subdirs
|
||||
// Read per-dashboard subdirs
|
||||
let entries: import('node:fs').Dirent[] = [];
|
||||
try {
|
||||
entries = await readdir(dirs.wsDir, { withFileTypes: true });
|
||||
entries = await readdir(dirs.dashDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || RESERVED_DIRS.has(entry.name)) continue;
|
||||
await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result);
|
||||
await readDashboardDir(join(dirs.dashDir, entry.name), entry.name, result);
|
||||
}
|
||||
|
||||
// Read project data — scan directories containing .officerdev/meta.json
|
||||
@@ -241,8 +256,8 @@ export async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<
|
||||
|
||||
export function getDirs(email: string): ResolveDirs {
|
||||
return {
|
||||
wsDir: getUserWorkspacesDir(email),
|
||||
screensDir: join(getUserWorkspacesDir(email), 'screens'),
|
||||
dashDir: getUserDashboardsDir(email),
|
||||
screensDir: join(getUserDashboardsDir(email), 'screens'),
|
||||
projDir: getUserProjectsDir(email),
|
||||
};
|
||||
}
|
||||
@@ -8,14 +8,12 @@ import * as errors from '@@/custom-errors';
|
||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
|
||||
async function getUserTtsVoice(email: string): Promise<string | null> {
|
||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
try {
|
||||
const settingsFile = Bun.file(getUserSettingsFile(email));
|
||||
if (await settingsFile.exists()) {
|
||||
const settings = (await settingsFile.json()) as { tts?: { voice?: string | null } };
|
||||
return settings.tts?.voice ?? null;
|
||||
}
|
||||
const settings = await getUserSettings(userId) as { tts?: { voice?: string | null } };
|
||||
return settings.tts?.voice ?? null;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
@@ -387,7 +385,7 @@ router.post('/tts', async (ctx) => {
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const userVoice = await getUserTtsVoice(user.email);
|
||||
const userVoice = await getUserTtsVoice(user.id);
|
||||
const voice = userVoice ?? ttsConfig.voice;
|
||||
|
||||
const userDataDir = getUserDataDir(user.email);
|
||||
@@ -439,7 +437,7 @@ router.post('/tts-text', async (ctx) => {
|
||||
const ttsConfig = await readTtsConfig();
|
||||
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
|
||||
|
||||
const userVoice = await getUserTtsVoice(user.email);
|
||||
const userVoice = await getUserTtsVoice(user.id);
|
||||
const voice = userVoice ?? ttsConfig.voice;
|
||||
|
||||
const userDataDir = getUserDataDir(user.email);
|
||||
@@ -906,16 +904,18 @@ router.post('/download-video', async (ctx) => {
|
||||
if (!path) throw errors.BAD_REQUEST('path is required');
|
||||
|
||||
const absPath = resolveUserPath(rootDir, path);
|
||||
const args = ['yt-dlp', '-o', '%(title)s.%(ext)s'];
|
||||
await mkdir(absPath, { recursive: true });
|
||||
const cookiesPath = join(DATA_PATH, '..', 'yt-dlp-cookies.txt');
|
||||
const args = ['yt-dlp', '--js-runtimes', 'bun', '--cookies', cookiesPath, '-o', '%(title)s.%(ext)s'];
|
||||
if (audioOnly) args.push('-x', '--audio-format', 'mp3');
|
||||
args.push(url);
|
||||
|
||||
const proc = Bun.spawn(args, { cwd: absPath, stdout: 'pipe', stderr: 'pipe' });
|
||||
const proc = Bun.spawn(args, { cwd: absPath, stdout: 'ignore', stderr: 'pipe' });
|
||||
const stderrText = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw errors.BAD_REQUEST(stderr.trim() || 'yt-dlp download failed');
|
||||
throw errors.BAD_REQUEST(stderrText.trim() || 'yt-dlp download failed');
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
@@ -932,12 +932,12 @@ router.post('/git-clone', async (ctx) => {
|
||||
const absPath = resolveUserPath(rootDir, path);
|
||||
await mkdir(absPath, { recursive: true });
|
||||
|
||||
const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'pipe', stderr: 'pipe' });
|
||||
const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'ignore', stderr: 'pipe' });
|
||||
const stderrText = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
throw errors.BAD_REQUEST(stderr.trim() || 'git clone failed');
|
||||
throw errors.BAD_REQUEST(stderrText.trim() || 'git clone failed');
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'officerdb';
|
||||
|
||||
const GOOGLE_SCOPES = [
|
||||
'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'https://mail.google.com/',
|
||||
'https://www.googleapis.com/auth/calendar.readonly',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
];
|
||||
@@ -131,6 +131,15 @@ integrationsRouter.get('/google/status', async (ctx) => {
|
||||
|
||||
integrationsRouter.delete('/google/connection', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const connection = await getUserIntegration(user.id, 'google');
|
||||
const connConfig = connection?.config as Record<string, unknown> | undefined;
|
||||
|
||||
// Revoke token at Google so the old grant is fully removed
|
||||
const token = (connConfig?.refreshToken as string) || (connConfig?.accessToken as string);
|
||||
if (token) {
|
||||
fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
|
||||
await deleteUserIntegration(user.id, 'google');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -157,6 +166,7 @@ integrationsRouter.get('/google/authorize', async (ctx) => {
|
||||
scope: GOOGLE_SCOPES.join(' '),
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
include_granted_scopes: 'false',
|
||||
state,
|
||||
});
|
||||
|
||||
@@ -244,7 +254,7 @@ export const googleCallbackHandler = async (ctx: any) => {
|
||||
// Auto-add /email to dock
|
||||
try {
|
||||
const existing = await getDockPaths(dbUser.id);
|
||||
const paths = existing ?? ['/', '/files', '/automation', '/projects', '/workspaces', '/chat'];
|
||||
const paths = existing ?? ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
||||
|
||||
if (!paths.includes('/email')) {
|
||||
paths.push('/email');
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readSearxngConfig } from "../server-settings/searxng";
|
||||
import { PI_CONFIG_DIR, DATA_PATH, SERVER_CONFIG_DIR, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
||||
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
|
||||
import { ensureDockerContainer } from "../terminal/websocket";
|
||||
import { getServerIntegration, getUserIntegration } from "officerdb";
|
||||
import { logger } from "./logger";
|
||||
@@ -161,34 +161,6 @@ function buildResourcesEnv(): string {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
async function ensureGoogleConfigFile(): Promise<string> {
|
||||
const filePath = join(SERVER_CONFIG_DIR, 'google-oauth.json');
|
||||
try {
|
||||
const integration = await getServerIntegration('google');
|
||||
if (integration?.config) {
|
||||
mkdirSync(SERVER_CONFIG_DIR, { recursive: true });
|
||||
writeFileSync(filePath, JSON.stringify(integration.config, null, 2));
|
||||
}
|
||||
} catch {
|
||||
// No google config available
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function ensureGoogleTokenFile(userId: number, email: string): Promise<string> {
|
||||
const dir = join(DATA_PATH, email, 'integrations');
|
||||
const filePath = join(dir, 'google.json');
|
||||
try {
|
||||
const integration = await getUserIntegration(userId, 'google');
|
||||
if (integration?.config) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, JSON.stringify(integration.config, null, 2));
|
||||
}
|
||||
} catch {
|
||||
// No user google integration available
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function getApifyToken(): Promise<string> {
|
||||
try {
|
||||
@@ -217,6 +189,19 @@ async function getBrowserRelayEnv(userId: number): Promise<Record<string, string
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApiKeyForModel(model: string): Promise<string | null> {
|
||||
const provider = model.split('/')[0];
|
||||
if (!provider) return null;
|
||||
try {
|
||||
const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json'));
|
||||
if (!(await authFile.exists())) return null;
|
||||
const auth = await authFile.json() as Record<string, { key?: string }>;
|
||||
return auth[provider]?.key?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type SandboxOptions = {
|
||||
userId: number;
|
||||
username: string;
|
||||
@@ -270,10 +255,12 @@ export async function spawnPi(
|
||||
if (model) piArgs.push('--model', model);
|
||||
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
|
||||
|
||||
// Pass API key for the model's provider so the container doesn't need auth.json
|
||||
const apiKey = await resolveApiKeyForModel(model);
|
||||
if (apiKey) piArgs.push('--api-key', apiKey);
|
||||
|
||||
const resourcesEnv = buildResourcesEnv();
|
||||
|
||||
const googleConfigHost = await ensureGoogleConfigFile();
|
||||
await ensureGoogleTokenFile(sandbox.userId, sandbox.email);
|
||||
const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId);
|
||||
const apifyToken = await getApifyToken();
|
||||
|
||||
@@ -284,8 +271,6 @@ export async function spawnPi(
|
||||
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
|
||||
'-e', `PI_SEARXNG_URL=${searxng.url}`,
|
||||
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
|
||||
'-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`,
|
||||
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
|
||||
'-e', `OFFICER_EMAIL_DB=/officer/emails.db`,
|
||||
...(apifyToken ? ['-e', `OFFICER_APIFY_TOKEN=${apifyToken}`] : []),
|
||||
...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
|
||||
@@ -330,8 +315,6 @@ export async function spawnPi(
|
||||
}
|
||||
|
||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
||||
const googleConfigPath = await ensureGoogleConfigFile();
|
||||
const googleTokenPath = await ensureGoogleTokenFile(userId, email);
|
||||
const browserRelayEnv = await getBrowserRelayEnv(userId);
|
||||
const apifyTokenLocal = await getApifyToken();
|
||||
|
||||
@@ -349,8 +332,6 @@ export async function spawnPi(
|
||||
PI_TOOLS_DIRS: toolsDirs,
|
||||
PI_SEARXNG_URL: searxng.url,
|
||||
OFFICER_RESOURCES: buildResourcesEnv(),
|
||||
OFFICER_GOOGLE_CONFIG_PATH: googleConfigPath,
|
||||
OFFICER_GOOGLE_TOKEN_PATH: googleTokenPath,
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
...(apifyTokenLocal ? { OFFICER_APIFY_TOKEN: apifyTokenLocal } : {}),
|
||||
...browserRelayEnv,
|
||||
|
||||
@@ -533,6 +533,9 @@ export async function listUserSessions(baseCwd: string, filter?: SessionFilter):
|
||||
if (filter.context === 'chat') {
|
||||
// 'chat' matches sessions with no context or context='chat'
|
||||
sessions = sessions.filter((s) => !s.context || s.context === 'chat');
|
||||
} else if (filter.context === 'dashboard') {
|
||||
// 'dashboard' matches both old 'workspace' and new 'dashboard' context
|
||||
sessions = sessions.filter((s) => (s.context === 'dashboard' || s.context === 'workspace') && s.contextId === filter.contextId);
|
||||
} else {
|
||||
sessions = sessions.filter((s) => s.context === filter.context && s.contextId === filter.contextId);
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ export const PROVIDERS: { key: string; piId: string }[] = [
|
||||
{ key: 'MiniMax', piId: 'minimax' },
|
||||
{ key: 'Hugging Face', piId: 'huggingface' },
|
||||
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
|
||||
{ key: 'OpenCode', piId: 'opencode' },
|
||||
{ key: 'OpenCode Zen', piId: 'zai' },
|
||||
{ key: 'Cerebras', piId: 'cerebras' },
|
||||
];
|
||||
@@ -414,6 +415,7 @@ const REMOTE_HEALTH_CONFIG: Record<string, {
|
||||
xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||
openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||
cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||
opencode: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||
zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR, PI_CONFIG_DIR } from '@@/data-path';
|
||||
import { getUsers, getServerIntegration, getUserIntegration } from 'officerdb';
|
||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, PI_CONFIG_DIR, toShellUsername } from '@@/data-path';
|
||||
import { getUsers } from 'officerdb';
|
||||
|
||||
const ensureDir = (dir: string) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; };
|
||||
|
||||
@@ -140,32 +140,6 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
|
||||
|
||||
const containerHome = `/home/${username}`;
|
||||
|
||||
// Write google-oauth config from DB to files for Docker mount
|
||||
const googleConfigHost = join(SERVER_CONFIG_DIR, 'google-oauth.json');
|
||||
let googleMounts: string[] = [];
|
||||
try {
|
||||
const googleIntegration = await getServerIntegration('google');
|
||||
if (googleIntegration?.config) {
|
||||
mkdirSync(SERVER_CONFIG_DIR, { recursive: true });
|
||||
writeFileSync(googleConfigHost, JSON.stringify(googleIntegration.config, null, 2));
|
||||
googleMounts = ['-v', `${googleConfigHost}:/officer/google-oauth.json:ro`];
|
||||
}
|
||||
} catch {
|
||||
// No google config — skip mount
|
||||
}
|
||||
|
||||
// Write per-user google token from DB for Docker mount
|
||||
const userIntegrationsDir = join(DATA_PATH, email, 'integrations');
|
||||
try {
|
||||
const userGoogle = await getUserIntegration(userId, 'google');
|
||||
if (userGoogle?.config) {
|
||||
mkdirSync(userIntegrationsDir, { recursive: true });
|
||||
writeFileSync(join(userIntegrationsDir, 'google.json'), JSON.stringify(userGoogle.config, null, 2));
|
||||
}
|
||||
} catch {
|
||||
// No user google integration — skip
|
||||
}
|
||||
|
||||
const run = Bun.spawnSync({
|
||||
cmd: [
|
||||
dockerPath,
|
||||
@@ -196,8 +170,6 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
|
||||
'-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`,
|
||||
'-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`,
|
||||
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
||||
...googleMounts,
|
||||
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
|
||||
'-v', `${join(DATA_PATH, email, 'emails.db')}:/officer/emails.db`,
|
||||
'-w', containerHome,
|
||||
tag,
|
||||
@@ -207,7 +179,13 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
|
||||
});
|
||||
|
||||
if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container');
|
||||
return { dockerId };
|
||||
|
||||
// Wait for entrypoint to finish (user creation, sidecar start)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
if (await sidecarAlive(port)) return { dockerId };
|
||||
}
|
||||
throw new Error('Terminal sidecar did not start in time');
|
||||
};
|
||||
|
||||
const stopDockerSidecar = (dockerId: string) => {
|
||||
@@ -263,8 +241,19 @@ const dockerStart = (dockerId: string) => {
|
||||
};
|
||||
|
||||
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => {
|
||||
// Check if mount sources are stale (e.g. data dir was deleted and Docker recreated them as root)
|
||||
// Must check BEFORE mkdirSync overwrites them
|
||||
const skillsDir = getUserSkillsDir(email);
|
||||
let stale = false;
|
||||
try {
|
||||
const s = statSync(skillsDir);
|
||||
if (s.uid === 0) stale = true;
|
||||
} catch {
|
||||
// doesn't exist yet — not stale, will be created below
|
||||
}
|
||||
|
||||
// Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing)
|
||||
mkdirSync(getUserSkillsDir(email), { recursive: true });
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
mkdirSync(getUserToolsDir(email), { recursive: true });
|
||||
mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true });
|
||||
|
||||
@@ -278,9 +267,9 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
||||
const existing = map[email];
|
||||
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) {
|
||||
// Recreate if resource mounts are missing (e.g. first run after feature was added)
|
||||
if (!containerHasExpectedMounts(existing.dockerId)) {
|
||||
console.log(`[terminal] recreating container for ${email} — resource mounts missing`);
|
||||
// Recreate if resource mounts are missing or data dir was recreated (stale mounts)
|
||||
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
|
||||
console.log(`[terminal] recreating container for ${email} — mounts stale or missing`);
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else {
|
||||
console.log(`[terminal] reusing running container ${existing.dockerId} for ${email} on port ${existing.port}`);
|
||||
@@ -289,7 +278,7 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
|
||||
}
|
||||
|
||||
if (existing && dockerContainerExists(existing.dockerId)) {
|
||||
if (!containerHasExpectedMounts(existing.dockerId)) {
|
||||
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else if (dockerStart(existing.dockerId)) {
|
||||
return existing;
|
||||
@@ -366,7 +355,7 @@ export const initTerminalSidecars = async () => {
|
||||
mkdirSync(dirname(homeDir), { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
try {
|
||||
await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!);
|
||||
await ensureDockerContainer(user.email, user.id, homeDir, toShellUsername(user.username ?? '', user.email));
|
||||
console.log(`[terminal] sidecar ready for ${user.email}`);
|
||||
} catch (err) {
|
||||
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export type KeyMapping = { file: string; dir?: string };
|
||||
|
||||
export type ResolveDirs = { wsDir: string; screensDir: string; projDir: string };
|
||||
@@ -8,6 +8,7 @@ import { enqueue } from '@@/queue/engine';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openEmailDb } from '@@/api/email/email-db';
|
||||
import type { ModelInfo } from '@@/api/pi/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/;
|
||||
const TYPING_INTERVAL_MS = 8_000;
|
||||
@@ -255,7 +256,7 @@ export async function handleDiscordMessage(message: DiscordMessage): Promise<voi
|
||||
const result = await sendAndAwait({
|
||||
userId: linked.user.id,
|
||||
email: linked.user.email,
|
||||
username: linked.user.username ?? linked.user.email.split('@')[0]!,
|
||||
username: toShellUsername(linked.user.username ?? '', linked.user.email),
|
||||
prompt: content,
|
||||
context: 'discord',
|
||||
contextId: discordId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { enqueue } from '@@/queue/engine';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openEmailDb } from '@@/api/email/email-db';
|
||||
import type { ModelInfo } from '@@/api/pi/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/;
|
||||
const TYPING_INTERVAL_MS = 5_000;
|
||||
@@ -267,7 +268,7 @@ export async function handleTelegramMessage(msg: TelegramBot.Message): Promise<v
|
||||
const result = await sendAndAwait({
|
||||
userId: linked.user.id,
|
||||
email: linked.user.email,
|
||||
username: linked.user.username ?? linked.user.email.split('@')[0]!,
|
||||
username: toShellUsername(linked.user.username ?? '', linked.user.email),
|
||||
prompt: content,
|
||||
context: 'telegram',
|
||||
contextId: telegramId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { enqueue } from '@@/queue/engine';
|
||||
import { readJob } from '@@/queue/storage';
|
||||
import { openEmailDb } from '@@/api/email/email-db';
|
||||
import type { ModelInfo } from '@@/api/pi/types';
|
||||
import { toShellUsername } from '@@/data-path';
|
||||
|
||||
const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/;
|
||||
const TYPING_INTERVAL_MS = 5_000;
|
||||
@@ -273,7 +274,7 @@ export async function handleWhatsAppMessage(msg: WAMessage): Promise<void> {
|
||||
const result = await sendAndAwait({
|
||||
userId: linked.user.id,
|
||||
email: linked.user.email,
|
||||
username: linked.user.username ?? linked.user.email.split('@')[0]!,
|
||||
username: toShellUsername(linked.user.username ?? '', linked.user.email),
|
||||
prompt: content,
|
||||
context: 'whatsapp',
|
||||
contextId: phone,
|
||||
|
||||
@@ -41,11 +41,11 @@ export const getUserStateDir = (email: string) => join(DATA_PATH, email, 'state'
|
||||
|
||||
export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state', 'state.json');
|
||||
|
||||
export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces');
|
||||
export const getUserDashboardsDir = (email: string) => join(DATA_PATH, email, 'dashboards');
|
||||
|
||||
export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'home', 'Projects');
|
||||
|
||||
export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
|
||||
export const getUserHomepageDashboardDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
|
||||
|
||||
export const getNativeSkillsDir = () => join(SEED_PATH, 'skills');
|
||||
|
||||
@@ -93,4 +93,11 @@ export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode'
|
||||
|
||||
export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails');
|
||||
|
||||
/** Derive a valid Linux username from a display username or email. */
|
||||
export const toShellUsername = (username: string, email: string): string => {
|
||||
const raw = username || email.split('@')[0]!;
|
||||
// Replace invalid chars, lowercase, truncate to 32 chars
|
||||
return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer';
|
||||
};
|
||||
|
||||
export const getUserAppsDir = (email: string) => join(DATA_PATH, email, 'apps');
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ import { sessionsRouter } from './api/sessions/sessions';
|
||||
import { scrapeRouter } from './api/scrape/scrape';
|
||||
import { uploadRouter } from './api/upload/upload';
|
||||
import { settingsRouter } from './api/settings/settings';
|
||||
import { workspacesRouter } from './api/workspaces';
|
||||
import { dashboardsRouter } from './api/dashboards';
|
||||
import { taskLogsRouter } from './api/task-logs/task-logs';
|
||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||
import { piRestRouter } from './api/pi/rest';
|
||||
@@ -78,7 +78,7 @@ protectedRouter.route('/', sessionsRouter);
|
||||
protectedRouter.route('/scrape', scrapeRouter);
|
||||
protectedRouter.route('/upload', uploadRouter);
|
||||
protectedRouter.route('/user', settingsRouter);
|
||||
protectedRouter.route('/workspaces', workspacesRouter);
|
||||
protectedRouter.route('/dashboards', dashboardsRouter);
|
||||
protectedRouter.route('/task-logs', taskLogsRouter);
|
||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
protectedRouter.route('/dev-server', devServerRouter);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import type { JobHandler } from '../types';
|
||||
import { registerHandler } from '../handler-registry';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { openEmailDb, upsertFromRawEml, getSyncMeta, setSyncMeta, updateEmailLabels } from '../../api/email/email-db';
|
||||
import { openEmailDb, upsertFromRawEml, getSyncMeta, setSyncMeta } from '../../api/email/email-db';
|
||||
import { getServerIntegration, getUserByEmail, getUserIntegration } from 'officerdb';
|
||||
|
||||
type GoogleCredentials = {
|
||||
@@ -76,267 +76,145 @@ async function getValidAccessToken(creds: GoogleCredentials): Promise<string> {
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me';
|
||||
// ── IMAP label mapping ──
|
||||
|
||||
async function gmailGet(token: string, path: string, params?: Record<string, string>): Promise<unknown> {
|
||||
const url = new URL(`${GMAIL_BASE}${path}`);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v) url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text().catch(() => '');
|
||||
throw new Error(`Gmail API error (${res.status}): ${error}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Pre-flight message count ──
|
||||
|
||||
async function countMessages(token: string, query?: string): Promise<number> {
|
||||
let count = 0;
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const params: Record<string, string> = { maxResults: '500' };
|
||||
if (query) params.q = query;
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
const list = (await gmailGet(token, '/messages', params)) as {
|
||||
messages?: Array<{ id: string }>;
|
||||
nextPageToken?: string;
|
||||
};
|
||||
count += list.messages?.length ?? 0;
|
||||
if (!list.messages?.length) break;
|
||||
pageToken = list.nextPageToken;
|
||||
} while (pageToken);
|
||||
return count;
|
||||
}
|
||||
|
||||
// ── Full sync: Gmail API → SQLite directly ──
|
||||
|
||||
type SyncProgress = { saved: number; skipped: number; errors: number; page: number };
|
||||
type OnProgress = (progress: SyncProgress) => void;
|
||||
|
||||
type SyncResult = {
|
||||
saved: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
labelMap: Map<string, string[]>;
|
||||
maxHistoryId: string | null;
|
||||
const SYSTEM_LABEL_MAP: Record<string, string> = {
|
||||
'\\Inbox': 'inbox',
|
||||
'\\Sent': 'sent',
|
||||
'\\Trash': 'trash',
|
||||
'\\Spam': 'spam',
|
||||
'\\Draft': 'draft',
|
||||
'\\Starred': 'starred',
|
||||
'\\Important': 'important',
|
||||
};
|
||||
|
||||
async function syncInbox(
|
||||
token: string,
|
||||
db: Database,
|
||||
function mapImapLabels(labels: Set<string> | undefined): string[] {
|
||||
if (!labels) return [];
|
||||
const mapped: string[] = [];
|
||||
for (const label of labels) {
|
||||
const system = SYSTEM_LABEL_MAP[label];
|
||||
if (system) {
|
||||
mapped.push(system);
|
||||
} else {
|
||||
mapped.push(label.toLowerCase());
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// ── IMAP sync ──
|
||||
|
||||
type ImapSyncResult = { saved: number; skipped: number; errors: number };
|
||||
|
||||
// Mailboxes to sync: All Mail has everything except Trash and Spam
|
||||
const SYNC_SPECIAL_USE = ['\\All', '\\Trash', '\\Junk'];
|
||||
|
||||
async function syncViaImap(
|
||||
accessToken: string,
|
||||
emailAccount: string,
|
||||
query?: string,
|
||||
onProgress?: OnProgress,
|
||||
): Promise<SyncResult> {
|
||||
// Dedup via DB
|
||||
const existingIds = new Set<string>();
|
||||
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
|
||||
for (const row of rows) existingIds.add(row.id);
|
||||
db: Database,
|
||||
since: Date | null,
|
||||
onProgress?: (saved: number, skipped: number) => void,
|
||||
): Promise<ImapSyncResult> {
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth: { user: emailAccount, accessToken },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (err) {
|
||||
console.error('[gmail-sync] IMAP connect failed:', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
let saved = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
let page = 0;
|
||||
let pageToken: string | undefined;
|
||||
const labelMap = new Map<string, string[]>();
|
||||
let maxHistoryId: bigint | null = null;
|
||||
|
||||
do {
|
||||
const params: Record<string, string> = { maxResults: '100' };
|
||||
if (query) params.q = query;
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
// Load existing IDs for dedup (once, shared across mailboxes)
|
||||
const existingIds = new Set<string>();
|
||||
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
|
||||
for (const row of rows) existingIds.add(row.id);
|
||||
|
||||
const list = (await gmailGet(token, '/messages', params)) as {
|
||||
messages?: Array<{ id: string }>;
|
||||
nextPageToken?: string;
|
||||
};
|
||||
try {
|
||||
// Find mailboxes by specialUse flag (locale-independent)
|
||||
const allMailboxes = await client.list();
|
||||
const toSync: Array<{ path: string; specialUse: string }> = [];
|
||||
for (const mailbox of allMailboxes) {
|
||||
if (mailbox.specialUse && SYNC_SPECIAL_USE.includes(mailbox.specialUse)) {
|
||||
toSync.push({ path: mailbox.path, specialUse: mailbox.specialUse });
|
||||
}
|
||||
}
|
||||
|
||||
const messages = list.messages ?? [];
|
||||
if (messages.length === 0) break;
|
||||
if (toSync.length === 0) {
|
||||
console.error('[gmail-sync] No mailboxes found to sync');
|
||||
return { saved, skipped, errors };
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i += 5) {
|
||||
const batch = messages.slice(i, i + 5);
|
||||
await Promise.all(
|
||||
batch.map(async ({ id }) => {
|
||||
if (existingIds.has(id)) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
for (const mailbox of toSync) {
|
||||
console.log(`[gmail-sync] Opening ${mailbox.path} (${mailbox.specialUse})...`);
|
||||
const lock = await client.getMailboxLock(mailbox.path);
|
||||
try {
|
||||
const searchCriteria = since ? { since } : { all: true };
|
||||
const uids = await client.search(searchCriteria, { uid: true });
|
||||
|
||||
if (!uids || uids.length === 0) {
|
||||
console.log(`[gmail-sync] ${mailbox.path}: no messages`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[gmail-sync] ${mailbox.path}: ${uids.length} messages`);
|
||||
|
||||
const uidRange = uids.join(',');
|
||||
const messages = client.fetch(uidRange, {
|
||||
source: true,
|
||||
labels: true,
|
||||
}, { uid: true });
|
||||
|
||||
for await (const msg of messages) {
|
||||
try {
|
||||
const msg = (await gmailGet(token, `/messages/${id}`, { format: 'raw' })) as {
|
||||
id: string;
|
||||
internalDate?: string;
|
||||
raw: string;
|
||||
labelIds?: string[];
|
||||
historyId?: string;
|
||||
};
|
||||
const rawEmail = Buffer.from(msg.raw, 'base64url').toString('utf-8');
|
||||
upsertFromRawEml({ db, id, raw: rawEmail, integration: 'gmail', emailAccount, labels: msg.labelIds });
|
||||
existingIds.add(id);
|
||||
if (!msg.emailId || !msg.source) continue;
|
||||
|
||||
const gmailId = BigInt(msg.emailId).toString(16);
|
||||
|
||||
if (existingIds.has(gmailId)) {
|
||||
skipped++;
|
||||
if ((saved + skipped) % 500 === 0) onProgress?.(saved, skipped);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawEmail = msg.source.toString('utf-8');
|
||||
const labels = mapImapLabels(msg.labels);
|
||||
|
||||
upsertFromRawEml({ db, id: gmailId, raw: rawEmail, integration: 'gmail', emailAccount, labels });
|
||||
existingIds.add(gmailId);
|
||||
saved++;
|
||||
|
||||
if (msg.labelIds) labelMap.set(id, msg.labelIds);
|
||||
if (msg.historyId) {
|
||||
const hid = BigInt(msg.historyId);
|
||||
if (maxHistoryId === null || hid > maxHistoryId) maxHistoryId = hid;
|
||||
if ((saved + skipped) % 100 === 0) {
|
||||
console.log(`[gmail-sync] Progress: saved ${saved}, skipped ${skipped}, errors ${errors}`);
|
||||
onProgress?.(saved, skipped);
|
||||
}
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
page++;
|
||||
console.log(`[gmail-sync] Page ${page}: saved ${saved}, skipped ${skipped}, errors ${errors}`);
|
||||
onProgress?.({ saved, skipped, errors, page });
|
||||
|
||||
pageToken = list.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
return { saved, skipped, errors, labelMap, maxHistoryId: maxHistoryId !== null ? String(maxHistoryId) : null };
|
||||
}
|
||||
|
||||
// ── Gmail History API (incremental sync) ──
|
||||
|
||||
type HistoryMessage = { id: string; labelIds?: string[] };
|
||||
|
||||
type HistoryRecord = {
|
||||
id: string;
|
||||
messagesAdded?: Array<{ message: HistoryMessage }>;
|
||||
messagesDeleted?: Array<{ message: HistoryMessage }>;
|
||||
labelsAdded?: Array<{ message: HistoryMessage; labelIds: string[] }>;
|
||||
labelsRemoved?: Array<{ message: HistoryMessage; labelIds: string[] }>;
|
||||
};
|
||||
|
||||
type HistoryResponse = {
|
||||
history?: HistoryRecord[];
|
||||
nextPageToken?: string;
|
||||
historyId: string;
|
||||
};
|
||||
|
||||
type IncrementalResult =
|
||||
| { stale: false; added: number; deleted: number; relabeled: number; maxHistoryId: string }
|
||||
| { stale: true };
|
||||
|
||||
async function syncIncremental(
|
||||
token: string,
|
||||
db: Database,
|
||||
lastHistoryId: string,
|
||||
emailAccount: string,
|
||||
): Promise<IncrementalResult> {
|
||||
let pageToken: string | undefined;
|
||||
let added = 0;
|
||||
let deleted = 0;
|
||||
let relabeled = 0;
|
||||
let latestHistoryId = lastHistoryId;
|
||||
|
||||
do {
|
||||
const url = new URL(`${GMAIL_BASE}/history`);
|
||||
url.searchParams.set('startHistoryId', lastHistoryId);
|
||||
for (const ht of ['messageAdded', 'messageDeleted', 'labelAdded', 'labelRemoved']) {
|
||||
url.searchParams.append('historyTypes', ht);
|
||||
}
|
||||
if (pageToken) url.searchParams.set('pageToken', pageToken);
|
||||
|
||||
let data: HistoryResponse;
|
||||
try {
|
||||
const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) {
|
||||
const error = await res.text().catch(() => '');
|
||||
throw new Error(`Gmail API error (${res.status}): ${error}`);
|
||||
}
|
||||
data = (await res.json()) as HistoryResponse;
|
||||
} catch (err) {
|
||||
// historyId too old — Gmail returns 404
|
||||
if (err instanceof Error && err.message.includes('404')) {
|
||||
return { stale: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
latestHistoryId = data.historyId;
|
||||
|
||||
for (const record of data.history ?? []) {
|
||||
// New messages
|
||||
for (const { message } of record.messagesAdded ?? []) {
|
||||
try {
|
||||
const msg = (await gmailGet(token, `/messages/${message.id}`, { format: 'raw' })) as {
|
||||
id: string;
|
||||
internalDate?: string;
|
||||
raw: string;
|
||||
labelIds?: string[];
|
||||
historyId?: string;
|
||||
};
|
||||
const rawEmail = Buffer.from(msg.raw, 'base64url').toString('utf-8');
|
||||
upsertFromRawEml({ db, id: msg.id, raw: rawEmail, integration: 'gmail', emailAccount, labels: msg.labelIds });
|
||||
added++;
|
||||
} catch {
|
||||
/* skip individual failures */
|
||||
}
|
||||
}
|
||||
|
||||
// Deleted messages
|
||||
for (const { message } of record.messagesDeleted ?? []) {
|
||||
db.run('UPDATE emails SET deleted = 1 WHERE id = ?', [message.id]);
|
||||
deleted++;
|
||||
}
|
||||
|
||||
// Labels added
|
||||
for (const { message, labelIds } of record.labelsAdded ?? []) {
|
||||
const row = db.query('SELECT labels FROM emails WHERE id = ?').get(message.id) as { labels: string | null } | null;
|
||||
if (row) {
|
||||
const existing = row.labels ? row.labels.split(',') : [];
|
||||
const merged = [...new Set([...existing, ...labelIds.map((l) => l.toLowerCase())])];
|
||||
updateEmailLabels(db, message.id, merged);
|
||||
relabeled++;
|
||||
}
|
||||
}
|
||||
|
||||
// Labels removed
|
||||
for (const { message, labelIds } of record.labelsRemoved ?? []) {
|
||||
const row = db.query('SELECT labels FROM emails WHERE id = ?').get(message.id) as { labels: string | null } | null;
|
||||
if (row) {
|
||||
const removeSet = new Set(labelIds.map((l) => l.toLowerCase()));
|
||||
const remaining = (row.labels ? row.labels.split(',') : []).filter((l) => !removeSet.has(l));
|
||||
updateEmailLabels(db, message.id, remaining);
|
||||
relabeled++;
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = data.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
return { stale: false, added, deleted, relabeled, maxHistoryId: latestHistoryId };
|
||||
}
|
||||
|
||||
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
function buildMonthRanges(year: number): Array<{ label: string; after: string; before: string }> {
|
||||
const now = new Date();
|
||||
const currentMonth = now.getFullYear() === year ? now.getMonth() : 11;
|
||||
const ranges: Array<{ label: string; after: string; before: string }> = [];
|
||||
|
||||
for (let m = 0; m <= currentMonth; m++) {
|
||||
const after = `${year}/${m + 1}/1`;
|
||||
const before = m < 11 ? `${year}/${m + 2}/1` : `${year + 1}/1/1`;
|
||||
ranges.push({ label: `${MONTH_NAMES[m]!} ${year}`, after, before });
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
|
||||
return ranges;
|
||||
return { saved, skipped, errors };
|
||||
}
|
||||
|
||||
// ── Handler ──
|
||||
|
||||
const gmailSyncHandler: JobHandler = {
|
||||
type: 'gmail-sync',
|
||||
steps: [
|
||||
@@ -356,102 +234,42 @@ const gmailSyncHandler: JobHandler = {
|
||||
|
||||
const db = openEmailDb(ctx.job.userId);
|
||||
try {
|
||||
// Try incremental sync first (only for non-year-scoped syncs)
|
||||
if (!year) {
|
||||
const lastHistoryId = getSyncMeta(db, 'last_history_id');
|
||||
if (lastHistoryId) {
|
||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Incremental sync' });
|
||||
console.log(`[gmail-sync] Attempting incremental sync from historyId ${lastHistoryId}`);
|
||||
const result = await syncIncremental(token, db, lastHistoryId, ctx.job.userId);
|
||||
if (!result.stale) {
|
||||
setSyncMeta(db, 'last_history_id', result.maxHistoryId);
|
||||
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
|
||||
console.log(`[gmail-sync] Incremental: +${result.added} added, -${result.deleted} deleted, ~${result.relabeled} relabeled`);
|
||||
await ctx.updateProgress({ current: 1, total: 1, label: 'Done (incremental)' });
|
||||
return;
|
||||
}
|
||||
console.log('[gmail-sync] historyId stale, falling back to full sync');
|
||||
// Bootstrap sync_meta from existing emails if DB was imported without metadata
|
||||
if (!getSyncMeta(db, 'last_sync_date')) {
|
||||
const newest = db.query('SELECT date FROM emails ORDER BY date DESC LIMIT 1').get() as { date: string } | null;
|
||||
if (newest?.date) {
|
||||
console.log(`[gmail-sync] Bootstrapping last_sync_date from existing DB: ${newest.date}`);
|
||||
setSyncMeta(db, 'last_sync_date', newest.date);
|
||||
}
|
||||
}
|
||||
|
||||
// Full sync
|
||||
let totalSaved = 0;
|
||||
let totalSkipped = 0;
|
||||
let totalErrors = 0;
|
||||
let allLabelMaps: Map<string, string[]> = new Map();
|
||||
let maxHistoryId: string | null = null;
|
||||
|
||||
// Determine since date
|
||||
let since: Date | null = null;
|
||||
if (year) {
|
||||
// Year-scoped sync: count total emails first, then sync month by month
|
||||
const yearQuery = `after:${year}/1/1 before:${year + 1}/1/1`;
|
||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' });
|
||||
const totalEmails = await countMessages(token, yearQuery);
|
||||
console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails for ${year}`);
|
||||
|
||||
const months = buildMonthRanges(year);
|
||||
for (let i = 0; i < months.length; i++) {
|
||||
const month = months[i]!;
|
||||
await ctx.updateProgress({ current: totalSaved + totalSkipped, total: totalEmails, label: month.label });
|
||||
const query = `after:${month.after} before:${month.before}`;
|
||||
const result = await syncInbox(token, db, ctx.job.userId, query, (p) => {
|
||||
const current = totalSaved + p.saved + p.skipped + p.errors;
|
||||
const label = `${month.label} — Saved ${(totalSaved + p.saved).toLocaleString()} of ${totalEmails.toLocaleString()}`;
|
||||
ctx.updateProgress({ current, total: totalEmails, label });
|
||||
});
|
||||
totalSaved += result.saved;
|
||||
totalSkipped += result.skipped;
|
||||
totalErrors += result.errors;
|
||||
for (const [id, labels] of result.labelMap) allLabelMaps.set(id, labels);
|
||||
if (result.maxHistoryId) {
|
||||
if (!maxHistoryId || BigInt(result.maxHistoryId) > BigInt(maxHistoryId)) {
|
||||
maxHistoryId = result.maxHistoryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' });
|
||||
since = new Date(year, 0, 1);
|
||||
} else {
|
||||
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
|
||||
let syncQuery: string | undefined;
|
||||
if (lastSyncDate) {
|
||||
const d = new Date(lastSyncDate);
|
||||
syncQuery = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
||||
console.log(`[gmail-sync] Scoping full sync with query: ${syncQuery}`);
|
||||
since = new Date(lastSyncDate);
|
||||
console.log(`[gmail-sync] Syncing since ${since.toISOString()}`);
|
||||
}
|
||||
|
||||
// Count total emails for accurate progress
|
||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' });
|
||||
const totalEmails = await countMessages(token, syncQuery);
|
||||
console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails`);
|
||||
|
||||
await ctx.updateProgress({ current: 0, total: totalEmails, label: 'Starting sync' });
|
||||
const result = await syncInbox(token, db, ctx.job.userId, syncQuery, (p) => {
|
||||
const current = p.saved + p.skipped + p.errors;
|
||||
const label = `Saved ${p.saved.toLocaleString()} of ${totalEmails.toLocaleString()}`;
|
||||
ctx.updateProgress({ current, total: totalEmails, label });
|
||||
});
|
||||
totalSaved = result.saved;
|
||||
totalSkipped = result.skipped;
|
||||
totalErrors = result.errors;
|
||||
allLabelMaps = result.labelMap;
|
||||
maxHistoryId = result.maxHistoryId;
|
||||
await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' });
|
||||
}
|
||||
|
||||
console.log(`[gmail-sync] Full: saved ${totalSaved}, skipped ${totalSkipped}, errors ${totalErrors}`);
|
||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Connecting via IMAP...' });
|
||||
|
||||
// Update labels for emails that were skipped but have new label data
|
||||
let labelsUpdated = 0;
|
||||
for (const [id, labels] of allLabelMaps) {
|
||||
updateEmailLabels(db, id, labels);
|
||||
labelsUpdated++;
|
||||
}
|
||||
if (labelsUpdated > 0) console.log(`[gmail-sync] Updated labels for ${labelsUpdated} emails`);
|
||||
const result = await syncViaImap(token, ctx.job.userId, db, since, (saved, skipped) => {
|
||||
const label = year
|
||||
? `${year} — Saved ${saved.toLocaleString()}, skipped ${skipped.toLocaleString()}`
|
||||
: `Saved ${saved.toLocaleString()}, skipped ${skipped.toLocaleString()}`;
|
||||
ctx.updateProgress({ current: saved + skipped, total: 0, label });
|
||||
});
|
||||
|
||||
// Store sync state
|
||||
if (maxHistoryId) {
|
||||
setSyncMeta(db, 'last_history_id', maxHistoryId);
|
||||
}
|
||||
console.log(`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`);
|
||||
|
||||
setSyncMeta(db, 'last_sync_date', new Date().toISOString().split('T')[0]!);
|
||||
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
|
||||
|
||||
await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${result.saved} new emails` });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user