@remit/mailbox-service 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -102,14 +102,26 @@ const buildStorageService = (
102
102
  };
103
103
  };
104
104
 
105
- /** A connection whose `fetchMessages` returns a hit for every uid in `present`. */
106
- const buildConnection = (present: Set<number>): IImapConnection =>
105
+ /**
106
+ * A connection whose `fetchMessages` returns a hit for every uid in `present`,
107
+ * except those in `fetchDrops` — messages the server still has and SEARCH
108
+ * still lists, whose FETCH row imapflow drops (#408).
109
+ */
110
+ const buildConnection = (
111
+ present: Set<number>,
112
+ fetchDrops: Set<number> = new Set(),
113
+ ): IImapConnection =>
107
114
  ({
108
115
  openBox: async () => ({}) as never,
109
116
  fetchMessages: async (uids: number[]) =>
110
117
  uids
111
- .filter((uid) => present.has(uid))
118
+ .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
112
119
  .map((uid) => ({ uid }) as unknown as never),
120
+ search: async (criteria: unknown[]) => {
121
+ const [, value] = (criteria as Array<[string, string]>)[0];
122
+ const uid = Number(value);
123
+ return present.has(uid) ? [uid] : [];
124
+ },
113
125
  }) as unknown as IImapConnection;
114
126
 
115
127
  describe("resolveExhaustedBodySyncFailures — the two terminal outcomes", () => {
@@ -189,6 +201,39 @@ describe("resolveExhaustedBodySyncFailures — the two terminal outcomes", () =>
189
201
  );
190
202
  });
191
203
 
204
+ it("a dropped FETCH row mid-batch is not absence: the live message keeps its rows", async () => {
205
+ const deletedMessages: string[] = [];
206
+ const deletedThreadMessages: Array<{
207
+ accountConfigId: string;
208
+ threadMessageId: string;
209
+ }> = [];
210
+ const { storageService } = buildStorageService();
211
+ const { log } = buildLogger();
212
+
213
+ const deps: ResolveExhaustedBodySyncDeps = {
214
+ messageService: buildMessageService({ m1: 1, m2: 2 }, deletedMessages),
215
+ threadMessageService: buildThreadMessageService(deletedThreadMessages),
216
+ storageService,
217
+ log,
218
+ };
219
+
220
+ // Both messages are live. The FETCH for m1 returns its row; the
221
+ // immediately following FETCH for m2 drops its row (#408).
222
+ const result = await resolveExhaustedBodySyncFailures(deps, {
223
+ accountId: "acc-1",
224
+ accountConfigId: "cfg-1",
225
+ mailboxId: "mbx-1",
226
+ mailboxPath: "INBOX",
227
+ failedMessageIds: ["m1", "m2"],
228
+ getConnection: async () => buildConnection(new Set([1, 2]), new Set([2])),
229
+ });
230
+
231
+ assert.deepEqual(result.reconciledMessageIds, []);
232
+ assert.deepEqual(result.brokenMessageIds, ["m1", "m2"]);
233
+ assert.deepEqual(deletedMessages, []);
234
+ assert.deepEqual(deletedThreadMessages, []);
235
+ });
236
+
192
237
  it("resolves a mixed batch into both outcomes independently", async () => {
193
238
  const deletedMessages: string[] = [];
194
239
  const { storageService } = buildStorageService();
@@ -1,6 +1,7 @@
1
1
  import type { IMessageRepository } from "@remit/data-ports";
2
2
  import type { StorageService } from "@remit/storage-service";
3
3
  import type { BodySyncLogger } from "./body-sync.js";
4
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
4
5
  import {
5
6
  reconcileStaleMessage,
6
7
  type StaleMessageReconcileDeps,
@@ -84,7 +85,11 @@ const markMessageBodySyncFailed = async (
84
85
  * outcome — every failed id lands in one of the two result lists.
85
86
  *
86
87
  * 1. EXPECTED — the message no longer exists on IMAP (expunged, or a
87
- * UIDVALIDITY change moved it, #1272). The stale row is deleted via
88
+ * UIDVALIDITY change moved it, #1272), confirmed by
89
+ * {@link isMessageGoneFromOpenMailbox}. This loop is the most exposed of
90
+ * the four sites that used to read an empty FETCH as absence: it issues
91
+ * back-to-back single-UID FETCHes on one connection, which is the
92
+ * condition imapflow #408 drops rows under. The stale row is deleted via
88
93
  * {@link reconcileStaleMessage} so the existing missing-row 404 path
89
94
  * takes over. Callers should emit a metric only — this is routine, not an
90
95
  * incident.
@@ -123,9 +128,8 @@ export const resolveExhaustedBodySyncFailures = async (
123
128
 
124
129
  for (const messageId of failedMessageIds) {
125
130
  const message = await deps.messageService.get(messageId);
126
- const found = await connection.fetchMessages([message.uid]);
127
131
 
128
- if (found.length === 0) {
132
+ if (await isMessageGoneFromOpenMailbox(connection, message.uid)) {
129
133
  const { threadMessagesDeleted } = await reconcileStaleMessage(
130
134
  deps,
131
135
  accountConfigId,
@@ -33,13 +33,21 @@ const buildLogger = (): {
33
33
  };
34
34
  };
35
35
 
36
- const buildConnection = (present: Set<number>): IImapConnection =>
36
+ const buildConnection = (
37
+ present: Set<number>,
38
+ fetchDrops: Set<number> = new Set(),
39
+ ): IImapConnection =>
37
40
  ({
38
41
  openBox: async () => ({}) as never,
39
42
  fetchMessages: async (uids: number[]) =>
40
43
  uids
41
- .filter((uid) => present.has(uid))
44
+ .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
42
45
  .map((uid) => ({ uid }) as unknown as never),
46
+ search: async (criteria: unknown[]) => {
47
+ const [, value] = (criteria as Array<[string, string]>)[0];
48
+ const uid = Number(value);
49
+ return present.has(uid) ? [uid] : [];
50
+ },
43
51
  }) as unknown as IImapConnection;
44
52
 
45
53
  describe("resolveExhaustedFlagPushFailure — the two terminal outcomes (mirrors #1289/#1270 for flag pushes)", () => {
@@ -104,6 +112,51 @@ describe("resolveExhaustedFlagPushFailure — the two terminal outcomes (mirrors
104
112
  assert.ok(metricLog, "expected a routine reconciliation metric log");
105
113
  });
106
114
 
115
+ it("a dropped FETCH row is not absence: the message is still in the mailbox — marker and local rows survive", async () => {
116
+ const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
117
+ const { log, errors } = buildLogger();
118
+
119
+ const deps: ResolveExhaustedFlagPushDeps = {
120
+ markerService: {
121
+ delete: async (messageId: string, flagName: string) => {
122
+ markerDeletes.push({ messageId, flagName });
123
+ },
124
+ },
125
+ messageService: {
126
+ delete: async () => {
127
+ throw new Error("must not be called — the message still exists");
128
+ },
129
+ } as unknown as Pick<IMessageRepository, "delete">,
130
+ threadMessageService: {
131
+ findAllByMessageId: async () => {
132
+ throw new Error("must not be called — the message still exists");
133
+ },
134
+ deleteMany: async () => {
135
+ throw new Error("must not be called — the message still exists");
136
+ },
137
+ } as unknown as Pick<
138
+ IThreadMessageRepository,
139
+ "findAllByMessageId" | "deleteMany"
140
+ >,
141
+ log,
142
+ };
143
+
144
+ const result = await resolveExhaustedFlagPushFailure(deps, {
145
+ accountId: "acc-1",
146
+ accountConfigId: "cfg-1",
147
+ messageId: "msg-live",
148
+ flagName: "\\Seen",
149
+ uid: 303,
150
+ mailboxPath: "INBOX",
151
+ getConnection: async () =>
152
+ buildConnection(new Set([303]), new Set([303])),
153
+ });
154
+
155
+ assert.equal(result.outcome, "broken");
156
+ assert.deepEqual(markerDeletes, []);
157
+ assert.ok(errors.some((e) => e.obj.alert === "flag_push_failed"));
158
+ });
159
+
107
160
  it("BROKEN (should never happen): the message still exists — marker left in place, alert logged", async () => {
108
161
  const markerDeletes: Array<{ messageId: string; flagName: string }> = [];
109
162
  const { log, errors } = buildLogger();
@@ -1,4 +1,5 @@
1
1
  import type { FlagPushLogger } from "./flag-push.js";
2
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
2
3
  import {
3
4
  reconcileStaleMessage,
4
5
  type StaleMessageReconcileDeps,
@@ -35,10 +36,11 @@ export interface ResolveExhaustedFlagPushResult {
35
36
  * (epic #1281 invariant 3) — no third, softer outcome.
36
37
  *
37
38
  * 1. RECONCILED (expected) — the message no longer exists at its mailbox on
38
- * IMAP. Per invariant 2, an external delete supersedes the marker
39
- * entirely: the marker is dropped and the stale Message/ThreadMessage rows
40
- * are deleted via {@link reconcileStaleMessage}. Metric only, no alarm
41
- * routine.
39
+ * IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather than by a
40
+ * FETCH coming back empty. Per invariant 2, an external delete supersedes
41
+ * the marker entirely: the marker is dropped and the stale
42
+ * Message/ThreadMessage rows are deleted via {@link reconcileStaleMessage}.
43
+ * Metric only, no alarm — routine.
42
44
  * 2. BROKEN — the message still exists, but the flag push keeps failing.
43
45
  * Broken code or a broken account, not a transient blip. The marker is
44
46
  * left in place (not cleared) — while pending, resync never reverts the
@@ -48,6 +50,15 @@ export interface ResolveExhaustedFlagPushResult {
48
50
  * entry for an operator alarm; never re-thrown (terminal — the caller acks
49
51
  * either way, since retrying a stale or permanently-broken push can never
50
52
  * succeed).
53
+ *
54
+ * An operator reading `flag_push_failed` should know one case where the
55
+ * message is not actually there: a message another client expunged
56
+ * mid-session can answer an empty FETCH while the server still lists its UID
57
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
58
+ * lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
59
+ * alert stands until someone clears it. The reverse mistake deletes live
60
+ * mail, so the cost is paid deliberately: a stale alert is recoverable, a
61
+ * deleted message is not.
51
62
  */
52
63
  export const resolveExhaustedFlagPushFailure = async (
53
64
  deps: ResolveExhaustedFlagPushDeps,
@@ -65,9 +76,8 @@ export const resolveExhaustedFlagPushFailure = async (
65
76
 
66
77
  const connection = await getConnection();
67
78
  await connection.openBox(mailboxPath, true);
68
- const found = await connection.fetchMessages([uid]);
69
79
 
70
- if (found.length === 0) {
80
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
71
81
  await deps.markerService.delete(messageId, flagName);
72
82
  const { threadMessagesDeleted } = await reconcileStaleMessage(
73
83
  deps,
@@ -112,6 +112,52 @@ const fakeMailbox = (path: string, exists: number) => ({
112
112
  readOnly: true,
113
113
  });
114
114
 
115
+ describe("ImapFlowConnection.search — the probe every stale-row reconcile rests on (#102)", () => {
116
+ const buildSearchConnection = (
117
+ result: unknown,
118
+ calls: Array<{ query: unknown; options: unknown }> = [],
119
+ ): ImapFlowConnection => {
120
+ const connection = buildConnectionWithClient({
121
+ mailboxOpen: async (path: string) => fakeMailbox(path, 1),
122
+ search: async (query: unknown, options: unknown) => {
123
+ calls.push({ query, options });
124
+ return result;
125
+ },
126
+ });
127
+ return connection;
128
+ };
129
+
130
+ it("translates a UID criterion into a UID SEARCH answering UIDs, not sequence numbers", async () => {
131
+ const calls: Array<{ query: unknown; options: unknown }> = [];
132
+ const connection = buildSearchConnection([42], calls);
133
+ await connection.openBox("INBOX", true);
134
+
135
+ const result = await connection.search([["UID", "42"]]);
136
+
137
+ assert.deepStrictEqual(result, [42]);
138
+ assert.deepStrictEqual(calls, [
139
+ { query: { uid: "42" }, options: { uid: true } },
140
+ ]);
141
+ });
142
+
143
+ it("answers an empty array when the server matched nothing", async () => {
144
+ const connection = buildSearchConnection([]);
145
+ await connection.openBox("INBOX", true);
146
+
147
+ assert.deepStrictEqual(await connection.search([["UID", "42"]]), []);
148
+ });
149
+
150
+ it("throws on a failed SEARCH instead of reporting it as no matches", async () => {
151
+ const connection = buildSearchConnection(false);
152
+ await connection.openBox("INBOX", true);
153
+
154
+ await assert.rejects(
155
+ () => connection.search([["UID", "42"]]),
156
+ /SEARCH failed/,
157
+ );
158
+ });
159
+ });
160
+
115
161
  describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
116
162
  it("returns the SEARCH \\Deleted count alongside the STATUS counts", async () => {
117
163
  const searchQueries: Array<Record<string, unknown>> = [];
@@ -152,7 +198,7 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
152
198
  highestModseq: 0n,
153
199
  }),
154
200
  mailboxOpen: async (path: string) => fakeMailbox(path, 4),
155
- search: async () => false,
201
+ search: async () => [],
156
202
  });
157
203
 
158
204
  const status = await connection.getMailboxStatus("INBOX");
@@ -173,7 +219,7 @@ describe("ImapFlowConnection.getMailboxStatus — deletedCount (#1042)", () => {
173
219
  highestModseq: modseq,
174
220
  }),
175
221
  mailboxOpen: async (path: string) => fakeMailbox(path, 1),
176
- search: async () => false,
222
+ search: async () => [],
177
223
  });
178
224
 
179
225
  const status = await connection.getMailboxStatus("INBOX");
@@ -397,7 +397,15 @@ export class ImapFlowConnection {
397
397
  };
398
398
 
399
399
  /**
400
- * Search for messages
400
+ * Search for messages, answering UIDs.
401
+ *
402
+ * imapflow answers a successful SEARCH with an array — empty when the
403
+ * server matched nothing — and `false` when the command failed or no
404
+ * mailbox was selected. Collapsing the two into `[]` makes a failed SEARCH
405
+ * indistinguishable from an empty mailbox, and every caller here acts on
406
+ * emptiness by deleting something: the cursor rebuild reads it as "every
407
+ * local row is stale", and {@link isMessageGoneFromOpenMailbox} reads it as
408
+ * "this message is gone". A failure therefore throws.
401
409
  */
402
410
  search = async (criteria: unknown[]): Promise<number[]> => {
403
411
  this.ensureConnected();
@@ -410,9 +418,8 @@ export class ImapFlowConnection {
410
418
  const searchQuery = this.convertSearchCriteria(criteria);
411
419
 
412
420
  const result = await this.client?.search(searchQuery, { uid: true });
413
- // search can return false if no messages match, or undefined if client is null
414
- if (!result) {
415
- return [];
421
+ if (!Array.isArray(result)) {
422
+ throw new Error(`IMAP SEARCH failed in mailbox ${this.currentMailbox}`);
416
423
  }
417
424
  return result;
418
425
  };
package/src/index.ts CHANGED
@@ -167,6 +167,7 @@ export {
167
167
  type ParsedMessageContent,
168
168
  parseMessageContent,
169
169
  } from "./message-parser.js";
170
+ export { isMessageGoneFromOpenMailbox } from "./message-presence.js";
170
171
  export {
171
172
  type ImapConnectionFactory,
172
173
  MessageSyncService,
@@ -0,0 +1,482 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IAddressRepository,
5
+ IEnvelopeRepository,
6
+ IMailboxRepository,
7
+ IMailboxSpecialUseRepository,
8
+ IMessageRepository,
9
+ IThreadMessageRepository,
10
+ IUnitOfWork,
11
+ } from "@remit/data-ports";
12
+ import { CreateFailedConflictError } from "@remit/data-ports/errors";
13
+ import { deriveCopyMessageId, deriveMessageId } from "@remit/data-ports/id";
14
+ import { MailboxCursorState } from "@remit/domain-enums";
15
+ import type { ManagedConnectionFactory } from "./connection-factory.js";
16
+ import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
17
+ import { MessageSyncService } from "./message-sync.js";
18
+ import type { IImapConnection, ImapMessage } from "./types.js";
19
+
20
+ // A copy is a per-folder placement of the same mail. These tests pin the three
21
+ // properties issue #75 turns on: the copy row is deterministic (idempotent), a
22
+ // delete removes exactly the placement it targets, and neither the copy nor the
23
+ // original leaves an unreachable row behind.
24
+
25
+ const ACCOUNT = "acc-1";
26
+ const ACCOUNT_CONFIG = "cfg-1";
27
+ const SOURCE_MAILBOX = "mbx-inbox";
28
+ const DEST_MAILBOX = "mbx-archive";
29
+ const HEADER = "<abc@example.com>";
30
+ const SOURCE_ID = deriveMessageId(ACCOUNT, HEADER);
31
+ const THREAD_ID = "thread-1";
32
+
33
+ interface ThreadRow {
34
+ accountConfigId: string;
35
+ threadMessageId: string;
36
+ threadId: string;
37
+ messageId: string;
38
+ mailboxId: string;
39
+ messageIdHeader: string;
40
+ isRead?: boolean;
41
+ hasStars?: boolean;
42
+ }
43
+
44
+ const buildWorld = () => {
45
+ const messages = new Map<string, Record<string, unknown>>([
46
+ [
47
+ SOURCE_ID,
48
+ {
49
+ messageId: SOURCE_ID,
50
+ mailboxId: SOURCE_MAILBOX,
51
+ uid: 42,
52
+ rfc822Size: 100,
53
+ internalDate: 1_700_000_000_000,
54
+ messageIdHeader: HEADER,
55
+ envelopeId: "env-1",
56
+ rootBodyPartId: "body-1",
57
+ bodyStorageKey: "s3://body-1",
58
+ category: "primary",
59
+ hasListUnsubscribe: false,
60
+ },
61
+ ],
62
+ ]);
63
+
64
+ const threadRows: ThreadRow[] = [
65
+ {
66
+ accountConfigId: ACCOUNT_CONFIG,
67
+ threadMessageId: `tm:${THREAD_ID}::${SOURCE_ID}`,
68
+ threadId: THREAD_ID,
69
+ messageId: SOURCE_ID,
70
+ mailboxId: SOURCE_MAILBOX,
71
+ messageIdHeader: HEADER,
72
+ isRead: false,
73
+ hasStars: false,
74
+ },
75
+ ];
76
+
77
+ const mailboxes = new Map<string, Record<string, unknown>>([
78
+ [
79
+ SOURCE_MAILBOX,
80
+ { mailboxId: SOURCE_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
81
+ ],
82
+ [
83
+ DEST_MAILBOX,
84
+ { mailboxId: DEST_MAILBOX, fullPath: "Archive", accountId: ACCOUNT },
85
+ ],
86
+ ]);
87
+
88
+ const messageService = {
89
+ get: async (id: string | string[]) => {
90
+ if (Array.isArray(id)) {
91
+ return id.map((i) => messages.get(i)).filter(Boolean);
92
+ }
93
+ const m = messages.get(id);
94
+ if (!m) throw new Error(`no message ${id}`);
95
+ return m;
96
+ },
97
+ // Faithful to the production repo: a duplicate messageId throws a
98
+ // conflict. create is NOT idempotent — only upsert is — so a copy path
99
+ // that relied on create swallowing the duplicate would strand the second
100
+ // attempt here (issue #75). The copy path uses upsert for exactly this
101
+ // reason.
102
+ create: async (input: Record<string, unknown>) => {
103
+ const id = input.messageId as string;
104
+ if (messages.has(id)) {
105
+ throw new CreateFailedConflictError("Message", input);
106
+ }
107
+ messages.set(id, { ...input });
108
+ return messages.get(id);
109
+ },
110
+ upsert: async (input: Record<string, unknown>) => {
111
+ const id = input.messageId as string;
112
+ if (!messages.has(id)) messages.set(id, { ...input });
113
+ return messages.get(id);
114
+ },
115
+ update: async (id: string, patch: Record<string, unknown>) => {
116
+ const m = messages.get(id);
117
+ if (m) Object.assign(m, patch);
118
+ return m;
119
+ },
120
+ } as unknown as IMessageRepository;
121
+
122
+ const threadMessageService = {
123
+ create: async (input: Record<string, unknown>) => {
124
+ const threadId = input.threadId as string;
125
+ const messageId = input.messageId as string;
126
+ const existing = threadRows.find(
127
+ (r) => r.threadId === threadId && r.messageId === messageId,
128
+ );
129
+ if (existing) return existing;
130
+ const row: ThreadRow = {
131
+ accountConfigId: input.accountConfigId as string,
132
+ threadMessageId: `tm:${threadId}::${messageId}`,
133
+ threadId,
134
+ messageId,
135
+ mailboxId: input.mailboxId as string,
136
+ messageIdHeader: input.messageIdHeader as string,
137
+ };
138
+ threadRows.push(row);
139
+ return row;
140
+ },
141
+ getByMessageId: async (_cfg: string, messageId: string) => {
142
+ const row = threadRows.find((r) => r.messageId === messageId);
143
+ if (!row) throw new Error(`no thread message ${messageId}`);
144
+ return row;
145
+ },
146
+ findByMessageId: async (_cfg: string, messageId: string) =>
147
+ threadRows.find((r) => r.messageId === messageId) ?? null,
148
+ findAllByMessageId: async (_cfg: string, messageId: string) =>
149
+ threadRows.filter((r) => r.messageId === messageId),
150
+ delete: async (_cfg: string, threadMessageId: string) => {
151
+ const idx = threadRows.findIndex(
152
+ (r) => r.threadMessageId === threadMessageId,
153
+ );
154
+ if (idx >= 0) threadRows.splice(idx, 1);
155
+ },
156
+ } as unknown as IThreadMessageRepository;
157
+
158
+ const mailboxService = {
159
+ get: async (_acc: string, id: string | string[]) => {
160
+ if (Array.isArray(id)) {
161
+ return id.map((i) => mailboxes.get(i)).filter(Boolean);
162
+ }
163
+ return mailboxes.get(id);
164
+ },
165
+ } as unknown as IMailboxRepository;
166
+
167
+ // No Trash configured, so deleteMessages takes the permanent-delete path —
168
+ // the branch that eagerly removes ThreadMessage rows (issue #212), which is
169
+ // where a copy's placement row must be reachable.
170
+ const mailboxSpecialUseService = {
171
+ findTrashMailbox: async () => null,
172
+ } as unknown as IMailboxSpecialUseRepository;
173
+
174
+ const config: MessageMoveConfig = {
175
+ messageService,
176
+ mailboxService,
177
+ mailboxSpecialUseService,
178
+ threadMessageService,
179
+ sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
180
+ };
181
+
182
+ const service = new MessageMoveService(config);
183
+ // The service builds a real SQS producer in its constructor; a unit test has
184
+ // no queue, so swap in a recorder.
185
+ const sent: unknown[] = [];
186
+ (
187
+ service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
188
+ ).sqs = {
189
+ send: async (command: unknown) => {
190
+ sent.push(command);
191
+ return {};
192
+ },
193
+ };
194
+
195
+ return {
196
+ service,
197
+ threadRows,
198
+ messages,
199
+ sent,
200
+ messageService,
201
+ threadMessageService,
202
+ };
203
+ };
204
+
205
+ // A message copied into the destination folder, as IMAP later enumerates it:
206
+ // same valid Message-ID header as the source, a real server UID. Sync derives
207
+ // its identity from that header (folder-independent), so the id it computes is
208
+ // the ORIGINAL's — not the copy's folder-scoped id.
209
+ const destinationServerMessage = (uid: number): ImapMessage =>
210
+ ({
211
+ uid,
212
+ seq: uid,
213
+ flags: [],
214
+ internalDate: new Date("2023-11-14T22:13:20Z"),
215
+ size: 100,
216
+ modseq: "510",
217
+ envelope: {
218
+ date: "Tue, 14 Nov 2023 22:13:20 +0000",
219
+ subject: "Hello",
220
+ from: [{ mailbox: "sender", host: "example.com" }],
221
+ sender: [],
222
+ replyTo: [],
223
+ to: [{ mailbox: "me", host: "example.com" }],
224
+ cc: [],
225
+ bcc: [],
226
+ inReplyTo: "",
227
+ messageId: HEADER,
228
+ },
229
+ }) as ImapMessage;
230
+
231
+ // A MessageSyncService that reads and writes the SAME rows the copy produced,
232
+ // so "copy then sync" is exercised against one shared world. A stored envelope
233
+ // with a valid Message-ID takes the CHANGEDSINCE path; anything the round tries
234
+ // to create lands in the shared maps, so a duplicate would be visible to the
235
+ // caller's row-count assertions.
236
+ const buildSyncOverDestination = (
237
+ world: ReturnType<typeof buildWorld>,
238
+ changed: ImapMessage[],
239
+ ) => {
240
+ const destMailbox = {
241
+ mailboxId: DEST_MAILBOX,
242
+ accountId: ACCOUNT,
243
+ fullPath: "Archive",
244
+ uidValidity: 100,
245
+ lastSyncUid: 1,
246
+ highWaterMarkUid: 20,
247
+ highestModseq: "500",
248
+ cursorState: MailboxCursorState.normal,
249
+ };
250
+
251
+ const connection = {
252
+ openBox: async () => ({ uidvalidity: 100, uidnext: 99 }),
253
+ getMailboxStatus: async () => ({
254
+ messages: 10,
255
+ recent: 0,
256
+ unseen: 1,
257
+ uidNext: 99,
258
+ uidValidity: 100,
259
+ highestModseq: "600",
260
+ deletedCount: 0,
261
+ }),
262
+ supportsCondstore: () => true,
263
+ fetchMessagesChangedSince: async () => changed,
264
+ search: async () => [],
265
+ fetchMessages: async () => [],
266
+ fetchEnvelopeSnapshots: async () => [],
267
+ } as unknown as IImapConnection;
268
+
269
+ const connectionFactory = {
270
+ getConnection: () => connection,
271
+ close: async () => {},
272
+ } as ManagedConnectionFactory;
273
+
274
+ const mailboxService = {
275
+ get: async () => destMailbox,
276
+ update: async () => destMailbox,
277
+ } as unknown as IMailboxRepository;
278
+
279
+ // A create that reaches DynamoDB would append to the shared maps, so the
280
+ // caller's "nothing accumulated" assertions catch a regression that mints a
281
+ // third row.
282
+ const unitOfWork: IUnitOfWork = {
283
+ transaction: (fn) =>
284
+ fn({
285
+ message: world.messageService,
286
+ envelope: {
287
+ upsertEnvelope: async () => undefined,
288
+ upsertBodyParts: async () => undefined,
289
+ } as unknown as IEnvelopeRepository,
290
+ address: {
291
+ upsertAddress: async () => undefined,
292
+ upsertEnvelopeAddress: async () => undefined,
293
+ } as unknown as IAddressRepository,
294
+ threadMessage: world.threadMessageService,
295
+ }),
296
+ };
297
+
298
+ return new MessageSyncService(
299
+ connectionFactory,
300
+ mailboxService,
301
+ world.messageService,
302
+ {} as IEnvelopeRepository,
303
+ {} as IAddressRepository,
304
+ world.threadMessageService,
305
+ undefined,
306
+ unitOfWork,
307
+ );
308
+ };
309
+
310
+ describe("MessageMoveService.copyMessage — deterministic per-folder identity (#75)", () => {
311
+ it("derives the copy id from source + destination, not at random", async () => {
312
+ const { service, threadRows } = buildWorld();
313
+
314
+ await service.copyMessages(
315
+ ACCOUNT_CONFIG,
316
+ [SOURCE_ID],
317
+ DEST_MAILBOX,
318
+ ACCOUNT,
319
+ );
320
+
321
+ const expected = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
322
+ const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
323
+ assert.ok(copyRow, "a placement row exists in the destination folder");
324
+ assert.equal(copyRow.messageId, expected);
325
+ });
326
+
327
+ it("a replayed copy converges on one row, no duplicate", async () => {
328
+ const { service, threadRows, messages } = buildWorld();
329
+
330
+ await service.copyMessages(
331
+ ACCOUNT_CONFIG,
332
+ [SOURCE_ID],
333
+ DEST_MAILBOX,
334
+ ACCOUNT,
335
+ );
336
+ // A replay (retried COPY event, or the user copying the same mail again)
337
+ // re-derives the same id. The copy path upserts, so the second write is a
338
+ // no-op on the existing row rather than the CreateFailedConflictError a
339
+ // plain create would throw — the operation is idempotent.
340
+ await service.copyMessages(
341
+ ACCOUNT_CONFIG,
342
+ [SOURCE_ID],
343
+ DEST_MAILBOX,
344
+ ACCOUNT,
345
+ );
346
+
347
+ const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
348
+ assert.equal(
349
+ threadRows.filter((r) => r.messageId === copyId).length,
350
+ 1,
351
+ "exactly one copy row after a replay",
352
+ );
353
+ assert.equal(
354
+ threadRows.length,
355
+ 2,
356
+ "the original and one copy — nothing accumulates",
357
+ );
358
+ assert.equal(messages.has(copyId), true);
359
+ });
360
+
361
+ it("copy then a destination-folder sync creates no third row", async () => {
362
+ const world = buildWorld();
363
+
364
+ await world.service.copyMessages(
365
+ ACCOUNT_CONFIG,
366
+ [SOURCE_ID],
367
+ DEST_MAILBOX,
368
+ ACCOUNT,
369
+ );
370
+
371
+ const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
372
+ assert.equal(world.threadRows.length, 2, "original plus one copy");
373
+ assert.equal(world.messages.size, 2, "original plus one copy");
374
+
375
+ // IMAP now enumerates the copied message in the destination folder. Sync
376
+ // derives its id from the valid Message-ID header — the folder-independent
377
+ // original id, not the copy's — finds the existing original row, and takes
378
+ // the flag-only branch. The crux of #75: no third or duplicate row.
379
+ const sync = buildSyncOverDestination(world, [
380
+ destinationServerMessage(77),
381
+ ]);
382
+ const result = await sync.syncMessages(
383
+ DEST_MAILBOX,
384
+ ACCOUNT,
385
+ ACCOUNT_CONFIG,
386
+ );
387
+
388
+ assert.equal(result.syncedCount, 0, "sync created no new message");
389
+ assert.equal(
390
+ world.threadRows.length,
391
+ 2,
392
+ "still original plus the copy — sync added nothing",
393
+ );
394
+ assert.equal(
395
+ world.messages.size,
396
+ 2,
397
+ "still original plus the copy — sync added nothing",
398
+ );
399
+ assert.equal(
400
+ world.threadRows.filter((r) => r.messageId === copyId).length,
401
+ 1,
402
+ "the copy row is untouched",
403
+ );
404
+ });
405
+
406
+ it("copy then delete of the copy removes the copy row and leaves the original", async () => {
407
+ const { service, threadRows } = buildWorld();
408
+
409
+ await service.copyMessages(
410
+ ACCOUNT_CONFIG,
411
+ [SOURCE_ID],
412
+ DEST_MAILBOX,
413
+ ACCOUNT,
414
+ );
415
+ const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
416
+
417
+ await service.deleteMessages(ACCOUNT_CONFIG, [copyId], ACCOUNT, {
418
+ permanent: true,
419
+ });
420
+
421
+ assert.equal(
422
+ threadRows.some((r) => r.messageId === copyId),
423
+ false,
424
+ "copy row removed",
425
+ );
426
+ assert.equal(
427
+ threadRows.some((r) => r.messageId === SOURCE_ID),
428
+ true,
429
+ "original row survives",
430
+ );
431
+ });
432
+
433
+ it("copy then delete of the original removes the original and leaves the copy — no orphan of the wrong row", async () => {
434
+ const { service, threadRows } = buildWorld();
435
+
436
+ await service.copyMessages(
437
+ ACCOUNT_CONFIG,
438
+ [SOURCE_ID],
439
+ DEST_MAILBOX,
440
+ ACCOUNT,
441
+ );
442
+ const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
443
+
444
+ await service.deleteMessages(ACCOUNT_CONFIG, [SOURCE_ID], ACCOUNT, {
445
+ permanent: true,
446
+ });
447
+
448
+ assert.equal(
449
+ threadRows.some((r) => r.messageId === SOURCE_ID),
450
+ false,
451
+ "original row removed",
452
+ );
453
+ assert.equal(
454
+ threadRows.some((r) => r.messageId === copyId),
455
+ true,
456
+ "copy row survives its own delete boundary",
457
+ );
458
+ });
459
+
460
+ it("a copy into two folders is two distinct, reachable rows", async () => {
461
+ const { service, threadRows } = buildWorld();
462
+
463
+ await service.copyMessages(
464
+ ACCOUNT_CONFIG,
465
+ [SOURCE_ID],
466
+ DEST_MAILBOX,
467
+ ACCOUNT,
468
+ );
469
+ await service.copyMessages(
470
+ ACCOUNT_CONFIG,
471
+ [SOURCE_ID],
472
+ SOURCE_MAILBOX,
473
+ ACCOUNT,
474
+ );
475
+
476
+ const idArchive = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
477
+ const idInbox = deriveCopyMessageId(SOURCE_ID, SOURCE_MAILBOX);
478
+ assert.notEqual(idArchive, idInbox);
479
+ assert.equal(threadRows.filter((r) => r.messageId === idArchive).length, 1);
480
+ assert.equal(threadRows.filter((r) => r.messageId === idInbox).length, 1);
481
+ });
482
+ });
@@ -10,7 +10,7 @@ import type {
10
10
  IMessageRepository,
11
11
  IThreadMessageRepository,
12
12
  } from "@remit/data-ports";
13
- import { base36uuid } from "@remit/data-ports/id";
13
+ import { deriveCopyMessageId } from "@remit/data-ports/id";
14
14
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
15
15
  import { createQueueProducer } from "@remit/sqs-client/producer";
16
16
 
@@ -450,11 +450,20 @@ export class MessageMoveService {
450
450
  destinationMailboxId,
451
451
  );
452
452
 
453
- // Generate new ID for the copy
454
- const newMessageId = base36uuid();
455
-
456
- // Create local copy with moving status (uid=0 until IMAP confirms)
457
- await this.messageService.create({
453
+ // Deterministic identity for the copy: derived from the source message and
454
+ // the destination mailbox, so a replayed COPY event or a repeated copy
455
+ // resolves to the same row instead of a fresh unreachable duplicate. A
456
+ // random id (the prior behaviour) left rows no sync or delete could reach
457
+ // (issue #75).
458
+ const newMessageId = deriveCopyMessageId(messageId, destinationMailboxId);
459
+
460
+ // Upsert, not create: the id is deterministic, so a repeated copy or a
461
+ // retry after a partial failure (row written, event never delivered)
462
+ // re-derives the same id. create throws on the existing row and strands
463
+ // the copy at uid=0 with an undelivered event; upsert no-ops on the row
464
+ // and lets the event be re-enqueued below, keeping the operation
465
+ // idempotent and the copy re-drivable (issue #75).
466
+ await this.messageService.upsert({
458
467
  messageId: newMessageId,
459
468
  mailboxId: destinationMailboxId,
460
469
  uid: 0, // Will be updated by worker after IMAP COPY
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
4
+ import type { IImapConnection } from "./types.js";
5
+
6
+ type Probe = Pick<IImapConnection, "fetchMessages" | "search">;
7
+
8
+ const buildProbe = (
9
+ fetched: number[],
10
+ searched: number[],
11
+ searchCalls: unknown[][] = [],
12
+ ): Probe => ({
13
+ fetchMessages: async () => fetched.map((uid) => ({ uid }) as never),
14
+ search: async (criteria: unknown[]) => {
15
+ searchCalls.push(criteria);
16
+ return searched;
17
+ },
18
+ });
19
+
20
+ describe("isMessageGoneFromOpenMailbox", () => {
21
+ it("a FETCH row is proof of presence — no SEARCH needed", async () => {
22
+ const searchCalls: unknown[][] = [];
23
+ const gone = await isMessageGoneFromOpenMailbox(
24
+ buildProbe([7], [], searchCalls),
25
+ 7,
26
+ );
27
+
28
+ assert.equal(gone, false);
29
+ assert.equal(searchCalls.length, 0);
30
+ });
31
+
32
+ it("an empty FETCH the SEARCH contradicts is a dropped row, not an absence", async () => {
33
+ const gone = await isMessageGoneFromOpenMailbox(buildProbe([], [7]), 7);
34
+
35
+ assert.equal(gone, false);
36
+ });
37
+
38
+ it("only a SEARCH that does not list the uid confirms it is gone", async () => {
39
+ const gone = await isMessageGoneFromOpenMailbox(buildProbe([], []), 7);
40
+
41
+ assert.equal(gone, true);
42
+ });
43
+
44
+ it("asks the server for the uid it is about to reconcile", async () => {
45
+ const searchCalls: unknown[][] = [];
46
+ await isMessageGoneFromOpenMailbox(buildProbe([], [], searchCalls), 42);
47
+
48
+ assert.deepEqual(searchCalls, [[["UID", "42"]]]);
49
+ });
50
+ });
@@ -0,0 +1,35 @@
1
+ import type { IImapConnection } from "./types.js";
2
+
3
+ /**
4
+ * Whether a UID is confirmed gone from the currently open mailbox.
5
+ *
6
+ * A FETCH that yields no row is not proof of absence. imapflow drops rows on
7
+ * back-to-back FETCHes (#408) — the same client glitch #100 had to stop
8
+ * reading as authoritative on the sync path — so a transient blip returns an
9
+ * empty result for a message that is still on the server. Anything that
10
+ * deletes local rows on that reading destroys live mail.
11
+ *
12
+ * Absence is therefore confirmed with a UID SEARCH, which the server answers
13
+ * with a plain UID set rather than a stream of message rows, and which
14
+ * `placement-move-push.ts` already uses as its verification probe. Only a
15
+ * SEARCH that does not list the UID counts as gone; an empty FETCH the SEARCH
16
+ * contradicts leaves the message present, and the caller treats it as such.
17
+ *
18
+ * The absence verdict rests on an empty SEARCH meaning the server matched
19
+ * nothing, so `IImapConnection.search` must throw when a SEARCH fails rather
20
+ * than answer `[]` — an implementation that reports failure as an empty array
21
+ * hands this function a deletion authority it cannot tell apart from a genuine
22
+ * miss.
23
+ */
24
+ export const isMessageGoneFromOpenMailbox = async (
25
+ connection: Pick<IImapConnection, "fetchMessages" | "search">,
26
+ uid: number,
27
+ ): Promise<boolean> => {
28
+ const found = await connection.fetchMessages([uid]);
29
+ if (found.length > 0) {
30
+ return false;
31
+ }
32
+
33
+ const matched = await connection.search([["UID", String(uid)]]);
34
+ return !matched.includes(uid);
35
+ };
@@ -33,13 +33,21 @@ const buildLogger = (): {
33
33
  };
34
34
  };
35
35
 
36
- const buildConnection = (present: Set<number>): IImapConnection =>
36
+ const buildConnection = (
37
+ present: Set<number>,
38
+ fetchDrops: Set<number> = new Set(),
39
+ ): IImapConnection =>
37
40
  ({
38
41
  openBox: async () => ({}) as never,
39
42
  fetchMessages: async (uids: number[]) =>
40
43
  uids
41
- .filter((uid) => present.has(uid))
44
+ .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
42
45
  .map((uid) => ({ uid }) as unknown as never),
46
+ search: async (criteria: unknown[]) => {
47
+ const [, value] = (criteria as Array<[string, string]>)[0];
48
+ const uid = Number(value);
49
+ return present.has(uid) ? [uid] : [];
50
+ },
43
51
  }) as unknown as IImapConnection;
44
52
 
45
53
  describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mirrors #1270 for placement moves)", () => {
@@ -148,6 +156,54 @@ describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mi
148
156
  );
149
157
  });
150
158
 
159
+ it("a dropped FETCH row is not absence: the message is still at the source — marker and local rows survive", async () => {
160
+ const deletedMessages: string[] = [];
161
+ const deletedThreadMessages: unknown[] = [];
162
+ const markerDeletes: string[] = [];
163
+ const { log, errors } = buildLogger();
164
+
165
+ const deps: ResolveExhaustedPlacementMoveDeps = {
166
+ markerService: {
167
+ delete: async (id: string) => {
168
+ markerDeletes.push(id);
169
+ },
170
+ },
171
+ messageService: {
172
+ delete: async (id: string) => {
173
+ deletedMessages.push(id);
174
+ },
175
+ } as unknown as Pick<IMessageRepository, "delete">,
176
+ threadMessageService: {
177
+ findAllByMessageId: async () => [
178
+ { accountConfigId: "cfg-1", threadMessageId: "tm-msg-live" },
179
+ ],
180
+ deleteMany: async (keys: unknown[]) => {
181
+ deletedThreadMessages.push(...keys);
182
+ },
183
+ } as unknown as Pick<
184
+ IThreadMessageRepository,
185
+ "findAllByMessageId" | "deleteMany"
186
+ >,
187
+ log,
188
+ };
189
+
190
+ const result = await resolveExhaustedPlacementMoveFailure(deps, {
191
+ accountId: "acc-1",
192
+ accountConfigId: "cfg-1",
193
+ messageId: "msg-live",
194
+ uid: 303,
195
+ sourceMailboxPath: "INBOX",
196
+ getConnection: async () =>
197
+ buildConnection(new Set([303]), new Set([303])),
198
+ });
199
+
200
+ assert.equal(result.outcome, "broken");
201
+ assert.deepEqual(markerDeletes, []);
202
+ assert.deepEqual(deletedMessages, []);
203
+ assert.deepEqual(deletedThreadMessages, []);
204
+ assert.ok(errors.some((e) => e.obj.alert === "placement_move_failed"));
205
+ });
206
+
151
207
  it("never throws — both outcomes are terminal, the caller always acks", async () => {
152
208
  const { log } = buildLogger();
153
209
  const deps: ResolveExhaustedPlacementMoveDeps = {
@@ -1,3 +1,4 @@
1
+ import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
1
2
  import type { PlacementMoveLogger } from "./placement-move.js";
2
3
  import {
3
4
  reconcileStaleMessage,
@@ -33,9 +34,11 @@ export interface ResolveExhaustedPlacementMoveResult {
33
34
  * taxonomy (epic #1281 invariant 3) — no third, softer outcome.
34
35
  *
35
36
  * 1. RECONCILED (expected) — the message no longer exists at its pending-move
36
- * source on IMAP. Per invariant 2, an external delete supersedes the
37
- * marker entirely: the marker is dropped and the stale Message/ThreadMessage
38
- * rows are deleted via {@link reconcileStaleMessage}. This is also the
37
+ * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
38
+ * than by a FETCH coming back empty. Per invariant 2, an external delete
39
+ * supersedes the marker entirely: the marker is dropped and the stale
40
+ * Message/ThreadMessage rows are deleted via
41
+ * {@link reconcileStaleMessage}. This is also the
39
42
  * outcome for the (rarer, functionally indistinguishable from here) case
40
43
  * where a foreign client moved the message elsewhere — either way, our
41
44
  * prediction no longer holds, and the marker cannot be honoured. Metric
@@ -49,6 +52,15 @@ export interface ResolveExhaustedPlacementMoveResult {
49
52
  * `alert`-shaped entry for an operator alarm; never re-thrown (terminal —
50
53
  * the caller acks either way, since retrying a stale or permanently-broken
51
54
  * move can never succeed).
55
+ *
56
+ * An operator reading `placement_move_failed` should know one case where the
57
+ * message is not actually at the source: a message another client expunged
58
+ * mid-session can answer an empty FETCH while the server still lists its UID
59
+ * in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
60
+ * lands in BROKEN, and BROKEN is terminal — the marker stays pending and the
61
+ * alert stands until someone clears it. The reverse mistake deletes live
62
+ * mail, so the cost is paid deliberately: a stale alert is recoverable, a
63
+ * deleted message is not.
52
64
  */
53
65
  export const resolveExhaustedPlacementMoveFailure = async (
54
66
  deps: ResolveExhaustedPlacementMoveDeps,
@@ -65,9 +77,8 @@ export const resolveExhaustedPlacementMoveFailure = async (
65
77
 
66
78
  const connection = await getConnection();
67
79
  await connection.openBox(sourceMailboxPath);
68
- const found = await connection.fetchMessages([uid]);
69
80
 
70
- if (found.length === 0) {
81
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
71
82
  await deps.markerService.delete(messageId);
72
83
  const { threadMessagesDeleted } = await reconcileStaleMessage(
73
84
  deps,