diff --git a/src/channels/__tests__/email.test.ts b/src/channels/__tests__/email.test.ts index ee9fa8b..85a326b 100644 --- a/src/channels/__tests__/email.test.ts +++ b/src/channels/__tests__/email.test.ts @@ -5,7 +5,18 @@ import { EmailChannel, type EmailChannelConfig } from "../email.ts"; const mockConnect = mock(() => Promise.resolve()); const mockLogout = mock(() => Promise.resolve()); const mockGetMailboxLock = mock(() => Promise.resolve({ release: () => {} })); -const mockIdle = mock(() => new Promise(() => {})); +const mockIdle = mock( + (opts?: { abort?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (opts?.abort) { + if (opts.abort.aborted) { + reject(new Error("abort")); + return; + } + opts.abort.addEventListener("abort", () => reject(new Error("abort")), { once: true }); + } + }), +); const mockFetch = mock(function* () { // Empty generator - no unread messages }); @@ -141,6 +152,34 @@ describe("EmailChannel", () => { expect(callArgs.text).toBe("Plain text content"); }); + test("disconnect awaits IDLE loop before logout", async () => { + const channel = new EmailChannel(testConfig); + await channel.connect(); + + // disconnect should complete without hanging — the IDLE loop + // must terminate before logout is called + await channel.disconnect(); + + // Verify logout was called (meaning IDLE loop finished first) + expect(mockLogout).toHaveBeenCalledTimes(1); + expect(channel.isConnected()).toBe(false); + }); + + test("rapid disconnect and reconnect does not leak IDLE loops", async () => { + const channel = new EmailChannel(testConfig); + await channel.connect(); + + await channel.disconnect(); + mockGetMailboxLock.mockClear(); + + // Reconnect should work cleanly without competing for the lock + await channel.connect(); + expect(channel.isConnected()).toBe(true); + expect(mockGetMailboxLock).toHaveBeenCalledTimes(1); + + await channel.disconnect(); + }); + test("send generates unique message ID", async () => { const channel = new EmailChannel(testConfig); await channel.connect(); diff --git a/src/channels/email.ts b/src/channels/email.ts index f0d283f..d5b4ebb 100644 --- a/src/channels/email.ts +++ b/src/channels/email.ts @@ -51,6 +51,7 @@ export class EmailChannel implements Channel { private transporter: NodemailerTransport | null = null; private threads = new Map(); private idleAbort: AbortController | null = null; + private idleLoopPromise: Promise | null = null; constructor(config: EmailChannelConfig) { this.config = config; @@ -86,8 +87,8 @@ export class EmailChannel implements Channel { this.connectionState = "connected"; console.log("[email] SMTP configured"); - // Start IDLE listening - void this.startIdleLoop(); + // Start IDLE listening (tracked so disconnect can await it) + this.idleLoopPromise = this.startIdleLoop(); } catch (err: unknown) { this.connectionState = "error"; const msg = err instanceof Error ? err.message : String(err); @@ -99,8 +100,16 @@ export class EmailChannel implements Channel { async disconnect(): Promise { if (this.connectionState === "disconnected") return; + this.connectionState = "disconnected"; this.idleAbort?.abort(); + // Wait for the IDLE loop to finish and release the mailbox lock + // before logging out, so a subsequent connect() won't race. + if (this.idleLoopPromise) { + await this.idleLoopPromise; + this.idleLoopPromise = null; + } + try { await this.imapClient?.logout(); } catch (err: unknown) { @@ -108,7 +117,6 @@ export class EmailChannel implements Channel { console.warn(`[email] Error during IMAP disconnect: ${msg}`); } - this.connectionState = "disconnected"; console.log("[email] Disconnected"); }