server/providers.test.ts

import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { google } from "googleapis";
import { ImapFlow } from "imapflow";
import { createServer } from "node:http";
import { providers, SyncError, syncError } from "./providers.js";
import type { StoredAccount } from "./store.js";
import type { Mail } from "../shared/types.js";

const account: StoredAccount = { id: "synthetic", email: "inbox@example.com", provider: "gmail", credentials: {} };
const cachedMail = (id: string): Mail => ({
  id: `${account.id}:${id}`, accountId: account.id, remoteId: id, sender: "Sender", address: "sender@example.com", to: account.email,
  subject: "Saved subject", preview: "Saved body", body: "Saved body", date: 1700000000000, unread: true,
  category: "other", relevance: 1, reason: "Taught sorting", archived: false, snoozedUntil: 1800000000000,
});
const raw = Buffer.from("From: Sender <sender@example.com>\r\nTo: inbox@example.com\r\nSubject: Fresh mail\r\n\r\nSynthetic body");

test("known IMAP timeouts and body-read disconnects remain safe transient failures", () => {
  for (const [input, phase, code] of [
    ["CONNECT_TIMEOUT", "connect", "ETIMEDOUT"],
    ["GREETING_TIMEOUT", "connect", "ETIMEDOUT"],
    ["NoConnection", "read", "CONNECTION_CLOSED"],
    ["EConnectionClosed", "read", "CONNECTION_CLOSED"],
  ] as const) {
    const error = syncError(Object.assign(new Error("Synthetic private transport details"), { code: input }), "imap", phase);
    assert.deepEqual(error.failure, { kind: "transient", phase, code });
    assert.match(error.message, /temporarily unavailable/);
    assert.ok(!error.message.includes("Synthetic private"));
  }
});

test("cached Gmail uses paginated inbox/unread lists without metadata gets, keeps older mail and exposes batches", async () => {
  const cached = new Map(Array.from({ length: 120 }, (_, i) => { const m = cachedMail(String(i)); return [m.id, m]; }));
  let lists = 0, gets = 0;
  const batches: number[] = [];
  const started = performance.now();
  const gmailMock = mock.method(google, "gmail", () => ({ users: { messages: {
    list: async (input: { labelIds: string[]; pageToken?: string }, options: { timeout?: number; retry?: boolean }) => {
      assert.equal(options?.timeout, 30000); assert.equal(options.retry, false); lists++;
      await new Promise((resolve) => setTimeout(resolve, 10));
      if (input.labelIds.includes("UNREAD")) return { data: { messages: [{ id: "119" }] } };
      const offset = input.pageToken ? 100 : 0;
      return { data: { messages: Array.from({ length: offset ? 20 : 100 }, (_, i) => ({ id: String(offset + i), threadId: `thread-${offset + i}` })), nextPageToken: offset ? undefined : "page2" } };
    },
    get: async () => { gets++; throw new Error("Cached bodies must not be fetched"); },
  } } }), {});
  try {
    const result = await providers.gmail.list(account, cached, (batch) => { batches.push(batch.length); });
    assert.equal(result.length, 120); assert.equal(gets, 0); assert.equal(lists, 3); assert.deepEqual(batches, [100, 20]);
    assert.equal(result.filter((m) => m.unread).length, 1);
    assert.equal(result[119].unread, true); assert.equal(result[0].threadId, "thread-0");
    assert.equal(result[0].body, "Saved body");
    console.log(`synthetic Gmail repeat: 120 cached, ${lists} lists, ${gets} gets, ${batches.length} visible batches, ${Math.round(performance.now() - started)}ms with 10ms/request`);
  } finally { gmailMock.mock.restore(); }
});

test("Gmail token, list and raw read timeouts abort actual transport and do not retry", async (t) => {
  // Positive cases get a generous closure bound: on loaded CI runners (macOS especially) socket teardown after an
  // abort can exceed 1s. The negative control keeps a short bound because its socket must never close on its own.
  async function checkTransport(phase: "token" | "list" | "raw", dropAbort = false, closeBoundMs = dropAbort ? 1000 : 10000) {
    const timeouts: AbortController[] = [];
    const cleanup = new AbortController();
    const reason = new DOMException("Synthetic private timeout details", "TimeoutError");
    let arrivals = 0, stalledCalls = 0;
    let arrived!: () => void, aborted!: () => void;
    const active = new Promise<void>((resolve) => { arrived = resolve; });
    const closed = new Promise<void>((resolve) => { aborted = resolve; });
    const server = createServer((req, res) => {
      arrivals++;
      req.resume();
      // Observe both the response and the underlying socket so a teardown is never missed.
      res.on("close", () => { if (!res.writableEnded) aborted(); });
      req.socket.once("close", () => aborted());
      // Fire the timeout only after the real transport reaches the server.
      arrived();
      timeouts.forEach((timeout) => timeout.abort(reason));
    });
    let restoreMocks = () => {};
    let rejected: Promise<void> | undefined, transport: Promise<Response> | undefined;
    async function withinBound(work: Promise<unknown>, message: string, ms = 1000) {
      let timer: ReturnType<typeof setTimeout> | undefined;
      try {
        await Promise.race([work, new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); })]);
      } finally { clearTimeout(timer); }
    }
    try {
      await new Promise<void>((resolve, reject) => server.once("error", reject).listen(0, "127.0.0.1", resolve));
      const address = server.address(); assert.ok(address && typeof address === "object");
      const originalGmail = google.gmail;
      const timeoutMock = mock.method(AbortSignal, "timeout", (ms: number) => {
        assert.equal(ms, 30000);
        const timeout = new AbortController();
        timeouts.push(timeout);
        return timeout.signal;
      });
      restoreMocks = () => timeoutMock.mock.restore();
      const gmailMock = mock.method(google, "gmail", (options: Parameters<typeof google.gmail>[0]) => {
        assert.ok(typeof options === "object" && options && typeof options.auth === "object");
        const auth = options.auth as InstanceType<typeof google.auth.OAuth2>;
        assert.equal(auth.transporter.defaults.timeout, 30000);
        assert.equal(auth.transporter.defaults.retry, false);
        assert.equal(auth.transporter.defaults.retryConfig?.retry, 0);
        auth.transporter.defaults.fetchImplementation = async (url, init) => {
          const token = String(url).includes("/token");
          const list = String(url).split("?")[0].endsWith("/messages");
          if ((phase === "token" && token) || (phase === "list" && list) || (phase === "raw" && !token && !list)) {
            stalledCalls++;
            const signal = init?.signal; assert.ok(signal);
            assert.equal(signal.aborted, false, "transport must start before timeout");
            transport = fetch(`http://127.0.0.1:${address.port}`, { ...init, signal: dropAbort ? cleanup.signal : signal });
            if (!dropAbort) return transport;
            // Negative control: reject the caller on timeout but leave its socket open.
            let rejectAbort!: () => void;
            const rejected = new Promise<never>((_resolve, reject) => { rejectAbort = () => reject(signal.reason); });
            signal.addEventListener("abort", rejectAbort, { once: true });
            try { return await Promise.race([transport, rejected]); }
            finally { signal.removeEventListener("abort", rejectAbort); }
          }
          return new Response(JSON.stringify(token
            ? { access_token: "synthetic-access", expires_in: 3600, token_type: "Bearer" }
            : { messages: [{ id: "one" }] }), { headers: { "Content-Type": "application/json" } });
        };
        return originalGmail.call(google, options);
      }, {});
      restoreMocks = () => { gmailMock.mock.restore(); timeoutMock.mock.restore(); };
      rejected = assert.rejects(providers.gmail.list({ ...account, credentials: { refreshToken: "synthetic-refresh" } }, new Map()), (error: unknown) => {
        assert.ok(error instanceof SyncError);
        assert.deepEqual(error.failure, { kind: "transient", phase: phase === "raw" ? "read" : "list", code: "ETIMEDOUT" });
        assert.match(error.message, /temporarily unavailable/);
        assert.ok(!error.message.includes(reason.message));
        return true;
      });
      void rejected.catch(() => {});
      await withinBound(Promise.all([active, rejected]), "Transport did not arrive or time out", 30000);
      assert.equal(arrivals, 1);
      assert.equal(stalledCalls, 1);
      await withinBound(closed, "Transport did not close", closeBoundMs);
      assert.equal(stalledCalls, 1);
      assert.equal(arrivals, 1);
      console.log(`synthetic Gmail ${phase}: 1 request arrived, transport closed after abort, 1 attempt`);
    } finally {
      timeouts.forEach((timeout) => timeout.abort(reason));
      cleanup.abort();
      restoreMocks();
      server.closeAllConnections();
      await Promise.allSettled([rejected, transport]);
      await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
    }
  }
  for (const phase of ["token", "list", "raw"] as const) {
    await t.test(phase, () => checkTransport(phase));
  }
  await t.test("missing transport abort fails the closure assertion", async () => {
    await assert.rejects(checkTransport("raw", true), { message: "Transport did not close" });
    console.log("synthetic Gmail negative control: caller timed out but open transport failed the 1000ms closure bound");
  });
});

test("fresh Gmail batches appear before the next page, and all in-flight gets settle on failure", async () => {
  let observed = 0, active = 0, peak = 0, settled = 0;
  const visible: { id: string }[][] = [];
  const gmailMock = mock.method(google, "gmail", () => ({ users: { messages: {
    list: async (input: { labelIds: string[]; pageToken?: string }) => {
      if (input.labelIds.includes("UNREAD")) return { data: {} };
      if (input.pageToken) { assert.equal(observed, 10); return { data: { messages: [{ id: "last" }] } }; }
      return { data: { messages: Array.from({ length: 10 }, (_, i) => ({ id: String(i) })), nextPageToken: "next" } };
    },
    get: async (input: { id: string; format: string }, options: { timeout: number; retry: boolean }) => {
      assert.equal(input.format, "raw"); assert.equal(options.timeout, 30000); assert.equal(options.retry, false);
      peak = Math.max(peak, ++active);
      await new Promise((resolve) => setTimeout(resolve, 5)); active--; settled++;
      return { data: { raw: raw.toString("base64url"), labelIds: ["UNREAD"], internalDate: "1700000000000" } };
    },
  } } }), {});
  try {
    const result = await providers.gmail.list(account, new Map(), (batch) => { observed += batch.length; visible.push(batch); });
    assert.equal(result.length, 11); assert.equal(observed, 11); assert.equal(peak, 10); assert.equal(settled, 11);
    assert.deepEqual(visible.map((batch) => batch.length), [10, 1]);
  } finally { gmailMock.mock.restore(); }
  let outstanding = 0;
  const failureMock = mock.method(google, "gmail", () => ({ users: { messages: {
    list: async (input: { labelIds: string[] }) => ({ data: { messages: input.labelIds.includes("UNREAD") ? [] : [{ id: "bad" }, { id: "slow" }] } }),
    get: async (input: { id: string }) => {
      if (input.id === "bad") throw new Error("Synthetic read timeout");
      outstanding++;
      await new Promise((resolve) => setTimeout(resolve, 20)); outstanding--;
      return { data: { raw: raw.toString("base64url") } };
    },
  } } }), {});
  try {
    await assert.rejects(providers.gmail.list(account, new Map()), (error: unknown) => {
      assert.ok(error instanceof SyncError);
      assert.deepEqual(error.failure, { kind: "message", phase: "read", code: "READ_FAILED" });
      assert.ok(!error.message.includes("Synthetic read timeout"));
      return true;
    });
    assert.equal(outstanding, 0, "the scheduler must not release a slot with provider work still running");
  } finally { failureMock.mock.restore(); }
});

test("IMAP refreshes flags without downloading cached bodies and drains metadata before UID body fetch", async () => {
  const calls: { range: string; source?: boolean }[] = [];
  let iterating = false, bytes = 0;
  const mocks = [
    mock.method(ImapFlow.prototype, "connect", async () => {}),
    mock.method(ImapFlow.prototype, "getMailboxLock", async function (this: ImapFlow) {
      this.mailbox = { exists: 3, uidValidity: BigInt(42) } as NonNullable<ImapFlow["mailbox"]>;
      return { release() {} };
    }),
    mock.method(ImapFlow.prototype, "close", () => {}),
    mock.method(ImapFlow.prototype, "fetch", async function* (range: string, query: { source?: boolean }, options?: { uid?: boolean }) {
      assert.equal(iterating, false, "no IMAP command inside another fetch iterator");
      calls.push({ range, source: query.source }); iterating = true;
      if (query.source) assert.equal(options?.uid, true);
      const uids = query.source ? range.split(",").map(Number) : [1, 2, 3];
      try {
        for (const uid of uids) {
          if (query.source) bytes += raw.length;
          yield { uid, flags: new Set(uid === 2 ? ["\\Seen"] : []), source: query.source ? raw : undefined };
        }
      } finally { iterating = false; }
    }),
  ];
  try {
    const cached = new Map([1, 2].map((uid) => { const m = cachedMail(`42:${uid}`); return [m.id, m]; }));
    let batches = 0;
    const result = await providers.imap.list(account, cached, () => { batches++; });
    assert.equal(result.length, 3); assert.equal(bytes, raw.length); assert.ok(batches >= 2);
    assert.deepEqual(calls, [{ range: "1:*", source: undefined }, { range: "3", source: true }]);
    assert.equal(result.find((m) => m.remoteId === "42:2")?.unread, false);
    assert.equal(result.find((m) => m.remoteId === "42:1")?.body, "Saved body");
    calls.length = 0; bytes = 0;
    const completeCache = new Map(result.map((m) => [m.id, { ...cachedMail(m.remoteId), ...m }]));
    await providers.imap.list(account, completeCache);
    assert.equal(bytes, 0); assert.equal(calls.length, 1);
    console.log("synthetic IMAP repeat: 3 cached bodies, 0 body bytes, 1 flag fetch");
  } finally { mocks.forEach((m) => m.mock.restore()); }
});