Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/channels/__tests__/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((_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
});
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 11 additions & 3 deletions src/channels/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class EmailChannel implements Channel {
private transporter: NodemailerTransport | null = null;
private threads = new Map<string, EmailThread>();
private idleAbort: AbortController | null = null;
private idleLoopPromise: Promise<void> | null = null;

constructor(config: EmailChannelConfig) {
this.config = config;
Expand Down Expand Up @@ -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);
Expand All @@ -99,16 +100,23 @@ export class EmailChannel implements Channel {
async disconnect(): Promise<void> {
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) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[email] Error during IMAP disconnect: ${msg}`);
}

this.connectionState = "disconnected";
console.log("[email] Disconnected");
}

Expand Down
Loading