diff --git a/packages/imap/src/commands/extensions.ts b/packages/imap/src/commands/extensions.ts index 96b7b9a..0b8b1ae 100644 --- a/packages/imap/src/commands/extensions.ts +++ b/packages/imap/src/commands/extensions.ts @@ -16,14 +16,39 @@ import type { UidMap } from "../uid-map.ts"; * Adapter interface for extension operations. */ export interface ExtensionAdapter { - copyMessage(messageId: string, targetFolderId: string): Promise<{ newUid: number }>; - moveMessage(messageId: string, targetFolderId: string): Promise<{ newUid: number }>; + /** + * RFC 4315 Section 3: COPY returns COPYUID with the DESTINATION + * mailbox's UIDVALIDITY. The adapter must return both the new UID + * assigned in the destination and the destination's uidValidity so + * the handler can emit the correct response code. + */ + copyMessage( + messageId: string, + targetFolderId: string, + ): Promise<{ newUid: number; uidValidity: number }>; + + /** + * RFC 6851 Section 4: MOVE inherits COPYUID semantics from RFC 4315. + * The COPYUID response code must carry the DESTINATION mailbox's + * UIDVALIDITY, not the source's. + */ + moveMessage( + messageId: string, + targetFolderId: string, + ): Promise<{ newUid: number; uidValidity: number }>; + + /** + * RFC 3501 Section 6.3.6 + RFC 4315: APPEND returns APPENDUID with + * the destination mailbox's UIDVALIDITY and the UID assigned to the + * newly appended message. Both come from the adapter. + */ appendMessage( folderId: string, content: string, flags: string[], internalDate?: Date, - ): Promise<{ uid: number; messageId: string }>; + ): Promise<{ uid: number; uidValidity: number; messageId: string }>; + getFolderIdByName(mailboxId: string, name: string): Promise; } @@ -61,6 +86,7 @@ export async function handleCopy( const sourceUids: number[] = []; const destUids: number[] = []; + let destUidValidity: number | null = null; for (const uid of uids) { const msgId = uidMap.uidToMessageId(uid); @@ -68,11 +94,19 @@ export async function handleCopy( const result = await adapter.copyMessage(msgId, targetFolderId); sourceUids.push(uid); destUids.push(result.newUid); + // All copied messages share one destination, so uidValidity is constant + // across the loop. Capture it on first hit. + if (destUidValidity === null) destUidValidity = result.uidValidity; + } + + if (sourceUids.length === 0 || destUidValidity === null) { + // RFC 4315: no messages copied, no COPYUID response code + return [formatTagged(tag, "OK", "COPY completed")]; } - // RFC 4315: COPYUID response code - const uidValidity = uidMap.uidValidity; - const copyUid = `[COPYUID ${uidValidity} ${sourceUids.join(",")} ${destUids.join(",")}]`; + // RFC 4315 Section 3: COPYUID response code uses the DESTINATION + // mailbox's UIDVALIDITY. The adapter returns it alongside the new UID. + const copyUid = `[COPYUID ${destUidValidity} ${sourceUids.join(",")} ${destUids.join(",")}]`; return [formatTagged(tag, "OK", `${copyUid} COPY completed`)]; } @@ -116,6 +150,7 @@ export async function handleMove( const responses: string[] = []; const sourceUids: number[] = []; const destUids: number[] = []; + let destUidValidity: number | null = null; // Move in reverse order to keep sequence numbers valid for EXPUNGE responses for (let i = uids.length - 1; i >= 0; i--) { @@ -126,13 +161,23 @@ export async function handleMove( const result = await adapter.moveMessage(msgId, targetFolderId); sourceUids.unshift(uid); destUids.unshift(result.newUid); + // All moved messages share one destination, so uidValidity is constant + // across the loop. Capture it on first hit. + if (destUidValidity === null) destUidValidity = result.uidValidity; const formerSeq = uidMap.expungeUid(uid); responses.push(formatExpungeResponse(formerSeq)); } - // RFC 6851 Section 4: COPYUID response code - const uidValidity = uidMap.uidValidity; - const copyUid = `[COPYUID ${uidValidity} ${sourceUids.join(",")} ${destUids.join(",")}]`; + if (sourceUids.length === 0 || destUidValidity === null) { + // No messages moved -- omit the COPYUID response code + responses.push(formatTagged(tag, "OK", "MOVE completed")); + return responses; + } + + // RFC 6851 Section 4: COPYUID response code inherits RFC 4315 semantics. + // First argument is the DESTINATION mailbox's UIDVALIDITY, not the + // source's. The adapter returns it alongside each new UID. + const copyUid = `[COPYUID ${destUidValidity} ${sourceUids.join(",")} ${destUids.join(",")}]`; responses.push(formatTagged(tag, "OK", `${copyUid} MOVE completed`)); return responses; } @@ -167,8 +212,11 @@ export async function handleAppend( const result = await adapter.appendMessage(folderId, parsed.content, parsed.flags, parsed.date); - // RFC 4315: APPENDUID response code - return [formatTagged(tag, "OK", `[APPENDUID 1 ${result.uid}] APPEND completed`)]; + // RFC 4315 Section 3: APPENDUID response code uses the destination + // mailbox's UIDVALIDITY, returned by the adapter alongside the new UID. + return [ + formatTagged(tag, "OK", `[APPENDUID ${result.uidValidity} ${result.uid}] APPEND completed`), + ]; } /** diff --git a/packages/imap/tests/commands/extensions.test.ts b/packages/imap/tests/commands/extensions.test.ts index e226fe5..17b6029 100644 --- a/packages/imap/tests/commands/extensions.test.ts +++ b/packages/imap/tests/commands/extensions.test.ts @@ -11,9 +11,13 @@ import { UidMap } from "../../src/uid-map.ts"; function mockAdapter(): ExtensionAdapter { return { - copyMessage: vi.fn(async () => ({ newUid: 200 })), - moveMessage: vi.fn(async () => ({ newUid: 201 })), - appendMessage: vi.fn(async () => ({ uid: 300, messageId: "new-msg" })), + copyMessage: vi.fn(async () => ({ newUid: 200, uidValidity: 42 })), + moveMessage: vi.fn(async () => ({ newUid: 201, uidValidity: 42 })), + appendMessage: vi.fn(async () => ({ + uid: 300, + uidValidity: 42, + messageId: "new-msg", + })), getFolderIdByName: vi.fn(async (_mbx: string, name: string) => name.toUpperCase() === "SENT" ? "folder-sent" : undefined, ), @@ -73,6 +77,25 @@ describe("RFC 3501 Section 6.4.7: COPY Command", () => { expect(responses[0]).toContain("COPYUID"); }); + // RFC 4315 Section 3: COPYUID response code returns the DESTINATION + // mailbox's UIDVALIDITY as its first argument, not the source's. The + // source session has uidValidity: 1 (selectedSession) and the mock + // adapter returns copies from a folder with uidValidity: 42. The + // response must carry 42 -- not 1 (source) and not any other constant. + it("returns destination UIDVALIDITY in COPYUID response (RFC 4315 Section 3)", async () => { + const adapter = mockAdapter(); + const responses = await handleCopy( + "a001", + "1:3 Sent", + selectedSession(), + loadedUidMap(), + "mbx-1", + adapter, + false, + ); + expect(responses[0]).toContain("[COPYUID 42 "); + }); + it("supports UID COPY", async () => { const adapter = mockAdapter(); await handleCopy("a001", "10 Sent", selectedSession(), loadedUidMap(), "mbx-1", adapter, true); @@ -141,6 +164,25 @@ describe("RFC 6851: MOVE Command", () => { expect(uidMap.totalMessages()).toBe(2); }); + // RFC 6851 Section 4 inherits the RFC 4315 COPYUID semantics for MOVE. + // The first argument is the DESTINATION mailbox's UIDVALIDITY. The mock + // moveMessage returns uidValidity: 42; the response must carry 42, not + // the source validity of 1. + it("returns destination UIDVALIDITY in MOVE's COPYUID response (RFC 6851 Section 4)", async () => { + const adapter = mockAdapter(); + const responses = await handleMove( + "a001", + "1 Sent", + selectedSession(), + loadedUidMap(), + "mbx-1", + adapter, + false, + ); + const tagged = responses[responses.length - 1]; + expect(tagged).toContain("[COPYUID 42 "); + }); + it("rejects MOVE on read-only folder", async () => { const responses = await handleMove( "a001", @@ -198,6 +240,23 @@ describe("RFC 3501 Section 6.3.6: APPEND Command", () => { expect(responses[0]).toContain("APPENDUID"); }); + // RFC 4315 Section 3: APPENDUID response code returns the destination + // mailbox's UIDVALIDITY as its first argument. The mock adapter returns + // uidValidity: 42; the response must carry that value, not a constant. + it("returns destination UIDVALIDITY in APPENDUID response (RFC 4315 Section 3)", async () => { + const adapter = mockAdapter(); + const session = new ImapSession(); + session.authenticate(); + const responses = await handleAppend( + "a001", + "Sent Message content here", + session, + "mbx-1", + adapter, + ); + expect(responses[0]).toContain("[APPENDUID 42 300]"); + }); + it("appends with flags", async () => { const adapter = mockAdapter(); const session = new ImapSession();