server/gmail-watch.ts
import type { StoredAccount } from "./store.js";
import { sameImapAccount } from "./imap-watch.js";
import { gmailHistoryId } from "./providers.js";
export const gmailPollInterval = 15000;
type Watch = {
account: StoredAccount;
timer?: ReturnType<typeof setTimeout>;
historyId?: string;
failures: number;
};
// Installed Gmail clients poll a cheap revision marker; full sync only follows a change.
export class GmailWatch {
private watches = new Map<string, Watch>();
constructor(
private changed: (account: StoredAccount) => Promise<boolean>,
private revision = gmailHistoryId,
) {}
reconcile(accounts: StoredAccount[]) {
const eligible = accounts.filter((account) => account.provider === "gmail" && account.credentials.refreshToken);
for (const [id, watch] of this.watches) {
if (!eligible.some((account) => sameImapAccount(account, watch.account))) {
clearTimeout(watch.timer);
this.watches.delete(id);
}
}
for (const account of eligible) {
if (this.watches.has(account.id)) continue;
const watch: Watch = { account, failures: 0 };
this.watches.set(account.id, watch);
this.schedule(watch);
}
}
close() {
for (const watch of this.watches.values()) clearTimeout(watch.timer);
this.watches.clear();
}
private schedule(watch: Watch) {
watch.timer = setTimeout(() => { void this.poll(watch); },
Math.min(gmailPollInterval * 2 ** watch.failures, 300000));
watch.timer.unref();
}
private async poll(watch: Watch) {
const current = () => this.watches.get(watch.account.id) === watch;
try {
const historyId = await this.revision(watch.account);
if (!current()) return;
if (historyId !== watch.historyId) {
if (!await this.changed(watch.account)) throw new Error("Mailbox sync did not complete.");
if (!current()) return;
// Do not advance the marker on failed/partial sync, or changes would be missed.
watch.historyId = historyId;
}
watch.failures = 0;
} catch {
watch.failures = Math.min(watch.failures + 1, 5);
} finally {
if (current()) this.schedule(watch);
}
}
}