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,707 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { openEmailDb, upsertFromRawEml, setSyncMeta, getSyncMeta } from './store';
|
||||
import { getUserByEmail, getUserIntegration, upsertUserIntegration, getServerIntegration, getDockPaths, setDockPaths } from 'officerdb';
|
||||
import { updateEmailAccountStatus, getEmailAccount } from '../db/queries';
|
||||
import { refreshGoogleAccessToken } from '@@/api/integrations/google-auth';
|
||||
|
||||
import type { SyncStep, SyncCtx } from './sync-runner';
|
||||
|
||||
// Thrown by the handlers for failures that a retry cannot fix (missing credentials, deleted account).
|
||||
// The queue used it to skip retrying; here it is an ordinary error whose message reaches the sync state.
|
||||
export class PermanentError extends Error {}
|
||||
|
||||
// ── Credentials ──
|
||||
|
||||
export type GmailCredentials = {
|
||||
email: string;
|
||||
userId: number;
|
||||
appPassword?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export async function loadGmailCredentials(userEmail: string): Promise<GmailCredentials> {
|
||||
const dbUser = await getUserByEmail(userEmail);
|
||||
if (!dbUser) throw new PermanentError('User not found');
|
||||
|
||||
const userGoogle = await getUserIntegration(dbUser.id, 'google');
|
||||
const config = userGoogle?.config as Record<string, unknown> | undefined;
|
||||
|
||||
const gmailEmail = (config?.email as string) ?? userEmail;
|
||||
|
||||
// Prefer OAuth tokens, fall back to app password
|
||||
if (config?.accessToken && config?.refreshToken) {
|
||||
return {
|
||||
email: gmailEmail,
|
||||
userId: dbUser.id,
|
||||
accessToken: config.accessToken as string,
|
||||
refreshToken: config.refreshToken as string,
|
||||
expiresAt: config.expiresAt as number | undefined,
|
||||
appPassword: config.imapAppPassword as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!config?.imapAppPassword) {
|
||||
throw new PermanentError('Gmail App Password not configured — set it in Settings → Integrations');
|
||||
}
|
||||
|
||||
return { email: gmailEmail, userId: dbUser.id, appPassword: config.imapAppPassword as string };
|
||||
}
|
||||
|
||||
// ── Stable ID from Message-Id header ──
|
||||
|
||||
function messageIdToStableId(raw: string): string | null {
|
||||
const match = raw.match(/^Message-Id:\s*<?([^>\s]+)>?/im);
|
||||
if (!match?.[1]) return null;
|
||||
return createHash('sha1').update(match[1]).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
// ── Gmail API label → our label mapping ──
|
||||
|
||||
const GMAIL_API_LABEL_MAP: Record<string, string> = {
|
||||
INBOX: 'inbox',
|
||||
SENT: 'sent',
|
||||
DRAFT: 'draft',
|
||||
STARRED: 'starred',
|
||||
IMPORTANT: 'important',
|
||||
TRASH: 'trash',
|
||||
SPAM: 'spam',
|
||||
CATEGORY_PROMOTIONS: 'promotions',
|
||||
CATEGORY_SOCIAL: 'social',
|
||||
CATEGORY_UPDATES: 'updates',
|
||||
CATEGORY_FORUMS: 'forums',
|
||||
};
|
||||
|
||||
const SKIP_LABELS = new Set(['TRASH', 'SPAM', 'DRAFT']);
|
||||
|
||||
function gmailApiLabelsToLabels(labelIds: string[]): string[] {
|
||||
const labels: string[] = [];
|
||||
for (const id of labelIds) {
|
||||
const mapped = GMAIL_API_LABEL_MAP[id];
|
||||
if (mapped) {
|
||||
labels.push(mapped);
|
||||
} else if (!id.startsWith('CATEGORY_') && id !== 'UNREAD' && id !== 'IMPORTANT') {
|
||||
// Custom label — use as-is (Gmail API returns label IDs, not names, for custom labels)
|
||||
labels.push(id.toLowerCase());
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function shouldSkipMessage(labelIds: string[]): boolean {
|
||||
return labelIds.some((id) => SKIP_LABELS.has(id));
|
||||
}
|
||||
|
||||
// ── Gmail API helpers ──
|
||||
|
||||
const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me';
|
||||
|
||||
export type GmailApiResult = { saved: number; skipped: number; errors: number };
|
||||
|
||||
async function gmailApiFetch(accessToken: string, path: string, params?: Record<string, string>): Promise<Response> {
|
||||
const url = new URL(`${GMAIL_API}${path}`);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
}
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
return res;
|
||||
}
|
||||
|
||||
async function getGmailProfile(accessToken: string): Promise<{ historyId: string }> {
|
||||
const res = await gmailApiFetch(accessToken, '/profile');
|
||||
if (!res.ok) throw new Error(`Gmail profile request failed: ${res.status} ${await res.text()}`);
|
||||
const data = (await res.json()) as { historyId: string };
|
||||
return data;
|
||||
}
|
||||
|
||||
type HistoryMessage = { id: string; threadId: string };
|
||||
type HistoryEntry = { messagesAdded?: Array<{ message: HistoryMessage }> };
|
||||
type HistoryResponse = { history?: HistoryEntry[]; historyId: string; nextPageToken?: string };
|
||||
|
||||
async function getHistoryChanges(
|
||||
accessToken: string,
|
||||
startHistoryId: string,
|
||||
): Promise<{ messageIds: string[]; historyId: string }> {
|
||||
const messageIds = new Set<string>();
|
||||
let pageToken: string | undefined;
|
||||
let latestHistoryId = startHistoryId;
|
||||
|
||||
while (true) {
|
||||
const params: Record<string, string> = {
|
||||
startHistoryId,
|
||||
historyTypes: 'messageAdded',
|
||||
maxResults: '500',
|
||||
};
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
|
||||
const res = await gmailApiFetch(accessToken, '/history', params);
|
||||
|
||||
if (res.status === 404 || res.status === 410) {
|
||||
// historyId too old or invalid — caller should fall back to date-based listing
|
||||
throw new HistoryExpiredError();
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`Gmail history request failed: ${res.status} ${await res.text()}`);
|
||||
|
||||
const data = (await res.json()) as HistoryResponse;
|
||||
latestHistoryId = data.historyId;
|
||||
|
||||
if (data.history) {
|
||||
for (const entry of data.history) {
|
||||
if (entry.messagesAdded) {
|
||||
for (const added of entry.messagesAdded) {
|
||||
messageIds.add(added.message.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.nextPageToken) break;
|
||||
pageToken = data.nextPageToken;
|
||||
}
|
||||
|
||||
return { messageIds: [...messageIds], historyId: latestHistoryId };
|
||||
}
|
||||
|
||||
class HistoryExpiredError extends Error {
|
||||
constructor() {
|
||||
super('Gmail historyId expired');
|
||||
}
|
||||
}
|
||||
|
||||
type MessageListResponse = { messages?: Array<{ id: string }>; nextPageToken?: string };
|
||||
|
||||
async function listMessagesSince(accessToken: string, sinceDate: string): Promise<string[]> {
|
||||
const messageIds: string[] = [];
|
||||
let pageToken: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const params: Record<string, string> = {
|
||||
q: `after:${sinceDate}`,
|
||||
maxResults: '500',
|
||||
};
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
|
||||
const res = await gmailApiFetch(accessToken, '/messages', params);
|
||||
if (!res.ok) throw new Error(`Gmail messages.list failed: ${res.status} ${await res.text()}`);
|
||||
|
||||
const data = (await res.json()) as MessageListResponse;
|
||||
if (data.messages) {
|
||||
for (const msg of data.messages) messageIds.push(msg.id);
|
||||
}
|
||||
|
||||
if (!data.nextPageToken) break;
|
||||
pageToken = data.nextPageToken;
|
||||
}
|
||||
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
type GmailMessage = { id: string; labelIds: string[]; raw: string; historyId: string };
|
||||
|
||||
async function fetchRawMessage(accessToken: string, messageId: string): Promise<GmailMessage | null> {
|
||||
const res = await gmailApiFetch(accessToken, `/messages/${messageId}`, { format: 'raw' });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Gmail messages.get failed: ${res.status} ${await res.text()}`);
|
||||
return (await res.json()) as GmailMessage;
|
||||
}
|
||||
|
||||
// ── Gmail API sync (for resyncs) ──
|
||||
|
||||
export type GmailApiSyncParams = {
|
||||
creds: GmailCredentials;
|
||||
db: Database;
|
||||
onProgress?: (saved: number, total: number) => void;
|
||||
};
|
||||
|
||||
export async function gmailApiSync({ creds, db, onProgress }: GmailApiSyncParams): Promise<GmailApiResult> {
|
||||
if (!creds.accessToken) throw new PermanentError('OAuth access token required for Gmail API sync');
|
||||
|
||||
let saved = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
// Load existing IDs for dedup
|
||||
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 storedHistoryId = getSyncMeta(db, 'gmail_history_id');
|
||||
|
||||
let messageIds: string[];
|
||||
let newHistoryId: string;
|
||||
|
||||
if (storedHistoryId) {
|
||||
// Try history-based sync first
|
||||
try {
|
||||
const result = await getHistoryChanges(creds.accessToken, storedHistoryId);
|
||||
messageIds = result.messageIds;
|
||||
newHistoryId = result.historyId;
|
||||
console.log(`[gmail-sync] History returned ${messageIds.length} new message(s)`);
|
||||
} catch (err) {
|
||||
if (err instanceof HistoryExpiredError) {
|
||||
// historyId too old — fall back to date-based listing
|
||||
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
|
||||
const sinceDate = lastSyncDate ?? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!;
|
||||
console.log(`[gmail-sync] History expired, falling back to messages since ${sinceDate}`);
|
||||
messageIds = await listMessagesSince(creds.accessToken, sinceDate);
|
||||
const profile = await getGmailProfile(creds.accessToken);
|
||||
newHistoryId = profile.historyId;
|
||||
console.log(`[gmail-sync] Date query returned ${messageIds.length} message(s)`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No historyId stored — use date-based listing
|
||||
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
|
||||
const sinceDate = lastSyncDate ?? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!;
|
||||
console.log(`[gmail-sync] No historyId, listing messages since ${sinceDate}`);
|
||||
messageIds = await listMessagesSince(creds.accessToken, sinceDate);
|
||||
const profile = await getGmailProfile(creds.accessToken);
|
||||
newHistoryId = profile.historyId;
|
||||
console.log(`[gmail-sync] Date query returned ${messageIds.length} message(s)`);
|
||||
}
|
||||
|
||||
// Fetch and store each new message
|
||||
for (let i = 0; i < messageIds.length; i++) {
|
||||
const gmailId = messageIds[i]!;
|
||||
|
||||
try {
|
||||
const msg = await fetchRawMessage(creds.accessToken, gmailId);
|
||||
if (!msg) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldSkipMessage(msg.labelIds ?? [])) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gmail API returns base64url-encoded raw RFC822
|
||||
const rawBytes = Buffer.from(msg.raw, 'base64url');
|
||||
const raw = rawBytes.toString('utf-8');
|
||||
|
||||
const id = messageIdToStableId(raw);
|
||||
if (!id) {
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingIds.has(id)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const labels = gmailApiLabelsToLabels(msg.labelIds ?? []);
|
||||
|
||||
upsertFromRawEml({ db, id, raw, integration: 'gmail', emailAccount: creds.email, labels });
|
||||
existingIds.add(id);
|
||||
saved++;
|
||||
|
||||
if ((saved + skipped) % 50 === 0) {
|
||||
onProgress?.(saved, messageIds.length);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[gmail-sync] Error fetching message ${gmailId}: ${err instanceof Error ? err.message : err}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist new historyId
|
||||
setSyncMeta(db, 'gmail_history_id', newHistoryId);
|
||||
|
||||
return { saved, skipped, errors };
|
||||
}
|
||||
|
||||
// ── IMAP sync (for first sync) ──
|
||||
|
||||
// Folder → label mapping for IMAP
|
||||
const GMAIL_PREFIX_RE = /^\[(?:Gmail|Google Mail)\]\//;
|
||||
|
||||
function normalizeGmailFolder(folder: string): string {
|
||||
return folder.replace(GMAIL_PREFIX_RE, '');
|
||||
}
|
||||
|
||||
const SUFFIX_LABEL_MAP: Record<string, string> = {
|
||||
'Sent Mail': 'sent',
|
||||
Drafts: 'draft',
|
||||
Starred: 'starred',
|
||||
Important: 'important',
|
||||
'All Mail': 'archive',
|
||||
Trash: 'trash',
|
||||
Bin: 'trash',
|
||||
Spam: 'spam',
|
||||
};
|
||||
|
||||
function folderToLabel(folder: string): string | null {
|
||||
const suffix = normalizeGmailFolder(folder);
|
||||
if (suffix !== folder) {
|
||||
if (SUFFIX_LABEL_MAP[suffix]) return SUFFIX_LABEL_MAP[suffix]!;
|
||||
return suffix.toLowerCase();
|
||||
}
|
||||
if (folder === 'INBOX') return 'inbox';
|
||||
return folder.toLowerCase();
|
||||
}
|
||||
|
||||
const IMAP_LABEL_MAP: Record<string, string> = {
|
||||
'\\Inbox': 'inbox',
|
||||
'\\Sent': 'sent',
|
||||
'\\Drafts': 'draft',
|
||||
'\\Starred': 'starred',
|
||||
'\\Important': 'important',
|
||||
'\\All': 'archive',
|
||||
'\\Trash': 'trash',
|
||||
'\\Junk': 'spam',
|
||||
};
|
||||
|
||||
function imapLabelsToLabels(gmailLabels: Set<string>): string[] {
|
||||
const labels: string[] = [];
|
||||
for (const gl of gmailLabels) {
|
||||
const mapped = IMAP_LABEL_MAP[gl];
|
||||
if (mapped) {
|
||||
labels.push(mapped);
|
||||
} else if (!gl.startsWith('\\')) {
|
||||
labels.push(gl.toLowerCase());
|
||||
}
|
||||
}
|
||||
if (labels.length > 1 && labels.includes('archive')) {
|
||||
return labels.filter((l) => l !== 'archive');
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
const SKIP_SPECIAL_USE = new Set(['\\Trash', '\\Junk', '\\All']);
|
||||
|
||||
function shouldSkipFolder(folder: { specialUse?: string; path: string; flags: Set<string> }): boolean {
|
||||
if (folder.flags.has('\\Noselect') || folder.flags.has('\\NonExistent')) return true;
|
||||
if (folder.specialUse && SKIP_SPECIAL_USE.has(folder.specialUse)) return true;
|
||||
const norm = normalizeGmailFolder(folder.path);
|
||||
return norm === 'All Mail' || norm === 'Trash' || norm === 'Spam' || norm === 'Bin';
|
||||
}
|
||||
|
||||
type ImapSyncParams = {
|
||||
creds: GmailCredentials;
|
||||
db: Database;
|
||||
onProgress?: (fetched: number, folder: string) => void;
|
||||
};
|
||||
|
||||
type SyncResult = { saved: number; skipped: number; errors: number };
|
||||
|
||||
async function imapFullSync({ creds, db, onProgress }: ImapSyncParams): Promise<SyncResult> {
|
||||
const { ImapFlow } = await import('imapflow');
|
||||
|
||||
const auth: { user: string; pass?: string; accessToken?: string } = { user: creds.email };
|
||||
if (creds.appPassword) {
|
||||
auth.pass = creds.appPassword;
|
||||
} else if (creds.accessToken) {
|
||||
auth.accessToken = creds.accessToken;
|
||||
} else {
|
||||
throw new PermanentError('No authentication method available for IMAP');
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.gmail.com',
|
||||
port: 993,
|
||||
secure: true,
|
||||
auth,
|
||||
logger: false,
|
||||
});
|
||||
|
||||
let saved = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log('[gmail-sync] IMAP connected for full sync');
|
||||
|
||||
const folders = await client.list({ statusQuery: { uidNext: true, uidValidity: true } });
|
||||
|
||||
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 foldersToSync: typeof folders = [];
|
||||
for (const folder of folders) {
|
||||
if (shouldSkipFolder(folder)) continue;
|
||||
|
||||
const folderPath = folder.path;
|
||||
const uidValidityKey = `imap_uidvalidity:${folderPath}`;
|
||||
const lastUidKey = `imap_lastuid:${folderPath}`;
|
||||
const storedUidValidity = getSyncMeta(db, uidValidityKey);
|
||||
const storedLastUid = getSyncMeta(db, lastUidKey);
|
||||
|
||||
const uidValidity = folder.status?.uidValidity ? String(folder.status.uidValidity) : null;
|
||||
const uidNext = folder.status?.uidNext ?? 0;
|
||||
const lastUid =
|
||||
storedUidValidity && uidValidity === storedUidValidity && storedLastUid ? parseInt(storedLastUid, 10) : 0;
|
||||
|
||||
if (uidValidity) setSyncMeta(db, uidValidityKey, uidValidity);
|
||||
if (lastUid > 0 && uidNext <= lastUid + 1) continue;
|
||||
|
||||
foldersToSync.push(folder);
|
||||
}
|
||||
|
||||
console.log(`[gmail-sync] ${foldersToSync.length} folder(s) to sync`);
|
||||
|
||||
for (const folder of foldersToSync) {
|
||||
const folderPath = folder.path;
|
||||
const uidValidityKey = `imap_uidvalidity:${folderPath}`;
|
||||
const lastUidKey = `imap_lastuid:${folderPath}`;
|
||||
const storedUidValidity = getSyncMeta(db, uidValidityKey);
|
||||
const storedLastUid = getSyncMeta(db, lastUidKey);
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = await client.getMailboxLock(folderPath);
|
||||
} catch (err) {
|
||||
console.log(`[gmail-sync] Skipping folder ${folderPath}: ${err instanceof Error ? err.message : err}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const mailbox = client.mailbox;
|
||||
if (!mailbox) continue;
|
||||
|
||||
const uidValidity = String(mailbox.uidValidity);
|
||||
let lastUid = 0;
|
||||
if (storedUidValidity === uidValidity && storedLastUid) {
|
||||
lastUid = parseInt(storedLastUid, 10);
|
||||
}
|
||||
|
||||
const range = lastUid > 0 ? `${lastUid + 1}:*` : '1:*';
|
||||
let maxUid = lastUid;
|
||||
let folderFetched = 0;
|
||||
|
||||
try {
|
||||
for await (const msg of client.fetch(range, { source: true, labels: true, uid: true }, { uid: true })) {
|
||||
if (msg.uid <= lastUid) continue;
|
||||
|
||||
maxUid = Math.max(maxUid, msg.uid);
|
||||
folderFetched++;
|
||||
|
||||
if (!msg.source) {
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = msg.source.toString('utf-8');
|
||||
const id = messageIdToStableId(raw);
|
||||
if (!id) {
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingIds.has(id)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderLabel = folderToLabel(folderPath);
|
||||
const gmailLabels = msg.labels ? imapLabelsToLabels(msg.labels) : [];
|
||||
const labels = folderLabel ? [...new Set([folderLabel, ...gmailLabels])] : gmailLabels;
|
||||
|
||||
try {
|
||||
upsertFromRawEml({ db, id, raw, integration: 'gmail', emailAccount: creds.email, labels });
|
||||
existingIds.add(id);
|
||||
saved++;
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
} catch (fetchErr) {
|
||||
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
||||
if (!msg.includes('Nothing to fetch')) {
|
||||
console.log(`[gmail-sync] Fetch error in ${folderPath}: ${msg}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxUid > lastUid) {
|
||||
setSyncMeta(db, lastUidKey, String(maxUid));
|
||||
}
|
||||
setSyncMeta(db, uidValidityKey, uidValidity);
|
||||
|
||||
if (folderFetched > 0) {
|
||||
console.log(`[gmail-sync] ${folderPath}: fetched ${folderFetched}, saved ${saved}, skipped ${skipped}`);
|
||||
onProgress?.(saved + skipped, folderPath);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
|
||||
return { saved, skipped, errors };
|
||||
}
|
||||
|
||||
// ── Handler ──
|
||||
|
||||
const gmailSyncHandler = {
|
||||
type: 'gmail-sync',
|
||||
retry: { delayMs: 15 * 60 * 1000, maxRetries: 10 },
|
||||
steps: [
|
||||
{
|
||||
name: 'Verify credentials',
|
||||
run: async (ctx: SyncCtx) => {
|
||||
const creds = await loadGmailCredentials(ctx.job.userId);
|
||||
const emailAccountId = (ctx.meta as Record<string, unknown>).emailAccountId as number | undefined;
|
||||
const syncAccount = emailAccountId ? await getEmailAccount(emailAccountId) : undefined;
|
||||
|
||||
// Determine sync mode — check SQLite first, fall back to PostgreSQL syncMeta
|
||||
const db = openEmailDb(ctx.job.userId, syncAccount?.email ?? ctx.job.userId);
|
||||
let lastSyncAt: string | null = null;
|
||||
try {
|
||||
lastSyncAt = getSyncMeta(db, 'last_sync_at');
|
||||
if (!lastSyncAt && emailAccountId) {
|
||||
const pgAccount = await getEmailAccount(emailAccountId);
|
||||
const pgMeta = pgAccount?.syncMeta as Record<string, string> | null;
|
||||
if (pgMeta?.last_sync_at) {
|
||||
lastSyncAt = pgMeta.last_sync_at;
|
||||
// Backfill SQLite so future checks don't need PostgreSQL
|
||||
setSyncMeta(db, 'last_sync_at', lastSyncAt);
|
||||
if (pgMeta.last_sync_date) setSyncMeta(db, 'last_sync_date', pgMeta.last_sync_date);
|
||||
}
|
||||
}
|
||||
ctx.meta.isFirstSync = !lastSyncAt;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
if (emailAccountId) {
|
||||
await updateEmailAccountStatus(emailAccountId, 'syncing');
|
||||
ctx.meta.emailAccountId = emailAccountId;
|
||||
}
|
||||
|
||||
// For API resyncs, refresh OAuth token if expired
|
||||
if (!ctx.meta.isFirstSync && creds.accessToken && creds.refreshToken) {
|
||||
const tokenExpired = !creds.expiresAt || creds.expiresAt < Date.now() + 60_000;
|
||||
if (tokenExpired) {
|
||||
console.log('[gmail-sync] Refreshing OAuth token');
|
||||
try {
|
||||
const refreshed = await refreshGoogleAccessToken(creds.refreshToken);
|
||||
creds.accessToken = refreshed.accessToken;
|
||||
creds.expiresAt = refreshed.expiresAt;
|
||||
|
||||
const userGoogle = await getUserIntegration(creds.userId, 'google');
|
||||
const existingConfig = (userGoogle?.config as Record<string, unknown>) ?? {};
|
||||
const serverGoogle = await getServerIntegration('google');
|
||||
await upsertUserIntegration({
|
||||
userId: creds.userId,
|
||||
provider: 'google',
|
||||
serverIntegrationId: serverGoogle?.id,
|
||||
config: { ...existingConfig, accessToken: refreshed.accessToken, expiresAt: refreshed.expiresAt },
|
||||
});
|
||||
} catch (err) {
|
||||
throw new PermanentError(`OAuth refresh failed — reconnect Google in Settings: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.meta.isFirstSync && !creds.accessToken) {
|
||||
throw new PermanentError('OAuth not configured — connect Google in Settings → Integrations for resyncs');
|
||||
}
|
||||
|
||||
ctx.meta.creds = creds;
|
||||
console.log(
|
||||
`[gmail-sync] ${ctx.meta.isFirstSync ? 'First sync (IMAP)' : 'Resync (Gmail API)'} — ${creds.accessToken ? 'OAuth' : 'App Password'}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sync emails',
|
||||
run: async (ctx: SyncCtx) => {
|
||||
const creds = ctx.meta.creds as GmailCredentials;
|
||||
|
||||
const emailAccountId = (ctx.meta as Record<string, unknown>).emailAccountId as number | undefined;
|
||||
const syncAccount = emailAccountId ? await getEmailAccount(emailAccountId) : undefined;
|
||||
const db = openEmailDb(ctx.job.userId, syncAccount?.email ?? ctx.job.userId);
|
||||
try {
|
||||
let result: SyncResult;
|
||||
|
||||
if (ctx.meta.isFirstSync) {
|
||||
// First sync: IMAP with app password
|
||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Connecting to Gmail (IMAP)...' });
|
||||
|
||||
result = await imapFullSync({
|
||||
creds,
|
||||
db,
|
||||
onProgress: (fetched, folder) => {
|
||||
ctx.updateProgress({ current: fetched, total: 0, label: `Syncing ${folder}...` });
|
||||
},
|
||||
});
|
||||
|
||||
// Seed historyId for future API syncs
|
||||
if (creds.accessToken) {
|
||||
try {
|
||||
const profile = await getGmailProfile(creds.accessToken);
|
||||
setSyncMeta(db, 'gmail_history_id', profile.historyId);
|
||||
console.log(`[gmail-sync] Seeded historyId: ${profile.historyId}`);
|
||||
} catch (err) {
|
||||
console.log(`[gmail-sync] Could not seed historyId: ${err}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Resync: Gmail API with OAuth
|
||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Checking for new emails...' });
|
||||
|
||||
result = await gmailApiSync({
|
||||
creds,
|
||||
db,
|
||||
onProgress: (saved, total) => {
|
||||
ctx.updateProgress({ current: saved, total, label: `Fetching new emails (${saved}/${total})...` });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
ctx.meta.syncResult = result;
|
||||
await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${result.saved} new emails` });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Finalize',
|
||||
run: async (ctx: SyncCtx) => {
|
||||
const result = ctx.meta.syncResult as SyncResult | undefined;
|
||||
const emailAccountId = ctx.meta.emailAccountId as number | undefined;
|
||||
|
||||
if (emailAccountId) {
|
||||
await updateEmailAccountStatus(emailAccountId, 'synced');
|
||||
}
|
||||
|
||||
if (result && result.saved > 0) {
|
||||
try {
|
||||
const dbUser = await getUserByEmail(ctx.job.userId);
|
||||
if (dbUser) {
|
||||
const paths = await getDockPaths(dbUser.id);
|
||||
if (!paths) {
|
||||
const defaults = ['/', '/files', '/automation', '/dashboards', '/chat'];
|
||||
await setDockPaths(dbUser.id, [...defaults, '/email']);
|
||||
} else if (!paths.includes('/email')) {
|
||||
await setDockPaths(dbUser.id, [...paths, '/email']);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** The handler's steps, for the sidecar's own runner. */
|
||||
export const gmailSyncSteps: SyncStep[] = gmailSyncHandler.steps as SyncStep[];
|
||||
Reference in New Issue
Block a user