Skip to content
Merged
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
70 changes: 59 additions & 11 deletions packages/imap/src/commands/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>;
}

Expand Down Expand Up @@ -61,18 +86,27 @@ 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);
if (msgId === undefined) continue;
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`)];
}

Expand Down Expand Up @@ -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--) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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`),
];
}

/**
Expand Down
65 changes: 62 additions & 3 deletions packages/imap/tests/commands/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand Down
Loading