@remit/imap-worker 0.0.60 → 0.0.62
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 +1 -1
- package/src/filter-config.test.ts +52 -0
- package/src/filter-config.ts +21 -1
- package/src/handlers/message-copy.test.ts +189 -15
- package/src/handlers/message-copy.ts +277 -63
- package/src/processor.ts +3 -2
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type PlacementMoveService,
|
|
16
16
|
} from "@remit/mailbox-service";
|
|
17
17
|
import { buildFilterConfig, type FilterConfigDeps } from "./filter-config.js";
|
|
18
|
+
import { _resetMessageEmbedderForTest } from "./message-embedder.js";
|
|
18
19
|
|
|
19
20
|
const anchorOnlyFilter = (destinationMailboxId: string): FilterItem =>
|
|
20
21
|
({
|
|
@@ -117,3 +118,54 @@ describe("buildFilterConfig", () => {
|
|
|
117
118
|
assert.equal(decision.move, undefined);
|
|
118
119
|
});
|
|
119
120
|
});
|
|
121
|
+
|
|
122
|
+
// An instance with semantic search off (issue #1068). `off` selects an embedder
|
|
123
|
+
// that throws by design, and the throw lands in the pipeline's per-filter catch:
|
|
124
|
+
// one error-level `filter_anchor_match_failed` for every semantic filter on
|
|
125
|
+
// every message it syncs. The pipeline already has a designed skip for a
|
|
126
|
+
// missing embedder, so the config passes none.
|
|
127
|
+
describe("buildFilterConfig with semantic search off", () => {
|
|
128
|
+
const withProviderOff = async (body: () => Promise<void>): Promise<void> => {
|
|
129
|
+
const previous = process.env.SEARCH_EMBEDDING_PROVIDER;
|
|
130
|
+
process.env.SEARCH_EMBEDDING_PROVIDER = "off";
|
|
131
|
+
_resetMessageEmbedderForTest();
|
|
132
|
+
try {
|
|
133
|
+
await body();
|
|
134
|
+
} finally {
|
|
135
|
+
if (previous === undefined) {
|
|
136
|
+
delete process.env.SEARCH_EMBEDDING_PROVIDER;
|
|
137
|
+
} else {
|
|
138
|
+
process.env.SEARCH_EMBEDDING_PROVIDER = previous;
|
|
139
|
+
}
|
|
140
|
+
_resetMessageEmbedderForTest();
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
it("wires no embedder", async () => {
|
|
145
|
+
await withProviderOff(async () => {
|
|
146
|
+
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
147
|
+
|
|
148
|
+
assert.ok(config);
|
|
149
|
+
assert.equal(config.embedder, undefined);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("skips the semantic filter without logging an error", async () => {
|
|
154
|
+
await withProviderOff(async () => {
|
|
155
|
+
const config = buildFilterConfig(repos(anchorOnlyFilter("mbx-archive")));
|
|
156
|
+
assert.ok(config);
|
|
157
|
+
|
|
158
|
+
const errors: unknown[] = [];
|
|
159
|
+
const decision = await new FilterPipeline(config, {
|
|
160
|
+
info: () => {},
|
|
161
|
+
debug: () => {},
|
|
162
|
+
warn: () => {},
|
|
163
|
+
error: (fields) => errors.push(fields.alert),
|
|
164
|
+
}).evaluate("cfg-1", "m-off", message);
|
|
165
|
+
|
|
166
|
+
assert.deepEqual(decision.labels, []);
|
|
167
|
+
assert.equal(decision.move, undefined);
|
|
168
|
+
assert.deepEqual(errors, []);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/filter-config.ts
CHANGED
|
@@ -8,6 +8,10 @@ import type {
|
|
|
8
8
|
MessageEmbedder,
|
|
9
9
|
PlacementMoveService,
|
|
10
10
|
} from "@remit/mailbox-service";
|
|
11
|
+
import {
|
|
12
|
+
EMBEDDING_PROVIDER_OFF,
|
|
13
|
+
readEmbeddingProviderFromEnv,
|
|
14
|
+
} from "@remit/search-service/from-env";
|
|
11
15
|
import { getMessageEmbedder } from "./message-embedder.js";
|
|
12
16
|
|
|
13
17
|
export interface FilterConfigDeps {
|
|
@@ -17,6 +21,21 @@ export interface FilterConfigDeps {
|
|
|
17
21
|
placementMoveService?: PlacementMoveService;
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The env-selected embedder, or none on an instance with semantic search off.
|
|
26
|
+
*
|
|
27
|
+
* `off` embeds nothing by design, and its embedder says so by throwing
|
|
28
|
+
* (EmbeddingDisabledError). The pipeline has a designed skip for a *missing*
|
|
29
|
+
* embedder — semantic filters are passed over and logged at debug — but a
|
|
30
|
+
* present embedder that throws lands in the per-filter catch instead, which is
|
|
31
|
+
* an error-level `filter_anchor_match_failed` for every semantic filter on
|
|
32
|
+
* every synced message. Same outcome, a log nobody can read.
|
|
33
|
+
*/
|
|
34
|
+
const embedderFromEnv = (): MessageEmbedder | undefined =>
|
|
35
|
+
readEmbeddingProviderFromEnv() === EMBEDDING_PROVIDER_OFF
|
|
36
|
+
? undefined
|
|
37
|
+
: getMessageEmbedder();
|
|
38
|
+
|
|
20
39
|
/**
|
|
21
40
|
* Assemble the index-time filter config the body-sync pass runs (RFC 034). The
|
|
22
41
|
* embedder is provisioned from env exactly as the backend read-path and
|
|
@@ -33,11 +52,12 @@ export const buildFilterConfig = (
|
|
|
33
52
|
): FilterConfig | undefined => {
|
|
34
53
|
const { placementMoveService } = deps;
|
|
35
54
|
if (!placementMoveService) return undefined;
|
|
55
|
+
const resolved = embedder ?? embedderFromEnv();
|
|
36
56
|
return {
|
|
37
57
|
filterService: deps.filterService,
|
|
38
58
|
filterAnchorService: deps.filterAnchorService,
|
|
39
59
|
messageLabelService: deps.messageLabelService,
|
|
40
60
|
placementMoveService,
|
|
41
|
-
embedder:
|
|
61
|
+
...(resolved ? { embedder: resolved } : {}),
|
|
42
62
|
};
|
|
43
63
|
};
|
|
@@ -28,9 +28,19 @@ interface Connection {
|
|
|
28
28
|
uids: number[],
|
|
29
29
|
dest: string,
|
|
30
30
|
) => Promise<{ uidMap: Map<number, number> }>;
|
|
31
|
+
search: (criteria: unknown[]) => Promise<number[]>;
|
|
31
32
|
createMailbox: (path: string) => Promise<void>;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
interface CopyRow {
|
|
36
|
+
messageId: string;
|
|
37
|
+
mailboxId: string;
|
|
38
|
+
uid: number;
|
|
39
|
+
status: string;
|
|
40
|
+
syncStatus: string;
|
|
41
|
+
messageIdHeader?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
interface Harness {
|
|
35
45
|
calls: Call[];
|
|
36
46
|
account: {
|
|
@@ -40,6 +50,8 @@ interface Harness {
|
|
|
40
50
|
} | null;
|
|
41
51
|
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
42
52
|
mailboxError?: Error;
|
|
53
|
+
copyRow: CopyRow | null;
|
|
54
|
+
destinationHolds: number[];
|
|
43
55
|
connection: Connection;
|
|
44
56
|
getConnectionCount: number;
|
|
45
57
|
disconnectCount: number;
|
|
@@ -59,15 +71,36 @@ const record =
|
|
|
59
71
|
};
|
|
60
72
|
|
|
61
73
|
const buildConnection = (): Connection => ({
|
|
62
|
-
openBox: async () =>
|
|
63
|
-
|
|
74
|
+
openBox: async (path: string, readOnly?: boolean) => {
|
|
75
|
+
h.calls.push({ method: "openBox", args: [path, readOnly] });
|
|
76
|
+
return { uidvalidity: 1 };
|
|
77
|
+
},
|
|
78
|
+
copyMessages: async (uids: number[], dest: string) => {
|
|
79
|
+
h.calls.push({ method: "copyMessages", args: [uids, dest] });
|
|
80
|
+
return { uidMap: new Map([[10, 20]]) };
|
|
81
|
+
},
|
|
82
|
+
search: async (criteria: unknown[]) => {
|
|
83
|
+
h.calls.push({ method: "search", args: criteria });
|
|
84
|
+
return h.destinationHolds;
|
|
85
|
+
},
|
|
64
86
|
createMailbox: record("createMailbox") as Connection["createMailbox"],
|
|
65
87
|
});
|
|
66
88
|
|
|
89
|
+
const unsettledCopyRow = (): CopyRow => ({
|
|
90
|
+
messageId: "new-msg",
|
|
91
|
+
mailboxId: "dst-mbx",
|
|
92
|
+
uid: 0,
|
|
93
|
+
status: "moving",
|
|
94
|
+
syncStatus: "pending",
|
|
95
|
+
messageIdHeader: "<abc@example.com>",
|
|
96
|
+
});
|
|
97
|
+
|
|
67
98
|
const fresh = (): Harness => ({
|
|
68
99
|
calls: [],
|
|
69
100
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
70
101
|
mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
|
|
102
|
+
copyRow: unsettledCopyRow(),
|
|
103
|
+
destinationHolds: [],
|
|
71
104
|
connection: buildConnection(),
|
|
72
105
|
getConnectionCount: 0,
|
|
73
106
|
disconnectCount: 0,
|
|
@@ -83,8 +116,13 @@ const deps = (): MessageCopyDeps =>
|
|
|
83
116
|
},
|
|
84
117
|
},
|
|
85
118
|
message: {
|
|
119
|
+
get: async (messageIds: string[]) => {
|
|
120
|
+
h.calls.push({ method: "message.get", args: [messageIds] });
|
|
121
|
+
return h.copyRow ? [h.copyRow] : [];
|
|
122
|
+
},
|
|
86
123
|
updateUid: record("message.updateUid"),
|
|
87
124
|
update: record("message.update"),
|
|
125
|
+
delete: record("message.delete"),
|
|
88
126
|
},
|
|
89
127
|
threadMessage: {
|
|
90
128
|
findByMessageId: async (cfg: string) => ({
|
|
@@ -97,6 +135,10 @@ const deps = (): MessageCopyDeps =>
|
|
|
97
135
|
hasStars: false,
|
|
98
136
|
hasAttachment: false,
|
|
99
137
|
}),
|
|
138
|
+
findAllByMessageId: async (cfg: string) => [
|
|
139
|
+
{ accountConfigId: cfg, threadMessageId: "tm-1" },
|
|
140
|
+
],
|
|
141
|
+
deleteMany: record("threadMessage.deleteMany"),
|
|
100
142
|
update: record("threadMessage.update"),
|
|
101
143
|
},
|
|
102
144
|
mailbox: {
|
|
@@ -141,13 +183,17 @@ const event: MessageCopyEvent = {
|
|
|
141
183
|
const called = (method: string): Call[] =>
|
|
142
184
|
h.calls.filter((c) => c.method === method);
|
|
143
185
|
|
|
186
|
+
const askedTheDestination = (): boolean =>
|
|
187
|
+
called("search").length > 0 &&
|
|
188
|
+
called("openBox").some((c) => c.args[0] === "Archive");
|
|
189
|
+
|
|
144
190
|
describe("handleMessageCopy", () => {
|
|
145
191
|
beforeEach(() => {
|
|
146
192
|
h = fresh();
|
|
147
193
|
});
|
|
148
194
|
|
|
149
195
|
it("writes the new UID, marks the copy synced, and updates the thread row", async () => {
|
|
150
|
-
await handleMessageCopy(event, noopLog, deps());
|
|
196
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
151
197
|
|
|
152
198
|
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
153
199
|
"new-msg",
|
|
@@ -163,18 +209,114 @@ describe("handleMessageCopy", () => {
|
|
|
163
209
|
assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
|
|
164
210
|
});
|
|
165
211
|
|
|
166
|
-
it("
|
|
212
|
+
it("settles on the destination UID when a server without UIDPLUS omits COPYUID", async () => {
|
|
213
|
+
h.connection.copyMessages = async () => ({ uidMap: new Map() });
|
|
214
|
+
h.destinationHolds = [42];
|
|
215
|
+
|
|
216
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
217
|
+
|
|
218
|
+
assert.ok(askedTheDestination(), "the destination was asked for the copy");
|
|
219
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
220
|
+
"new-msg",
|
|
221
|
+
42,
|
|
222
|
+
"dst-mbx",
|
|
223
|
+
]);
|
|
224
|
+
assert.equal(
|
|
225
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
226
|
+
?.syncStatus,
|
|
227
|
+
"synced",
|
|
228
|
+
);
|
|
229
|
+
assert.equal(called("threadMessage.update").length, 1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("reconciles the optimistic row away when the destination does not hold the copy", async () => {
|
|
167
233
|
h.connection.copyMessages = async () => ({ uidMap: new Map() });
|
|
234
|
+
h.destinationHolds = [];
|
|
168
235
|
|
|
169
|
-
await handleMessageCopy(event, noopLog, deps());
|
|
236
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
170
237
|
|
|
238
|
+
assert.ok(askedTheDestination());
|
|
239
|
+
assert.equal(called("message.delete")[0]?.args[0], "new-msg");
|
|
240
|
+
assert.equal(called("threadMessage.deleteMany").length, 1);
|
|
171
241
|
assert.equal(called("message.updateUid").length, 0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("settles a copy it cannot probe instead of retrying it into duplicates", async () => {
|
|
245
|
+
let copies = 0;
|
|
246
|
+
h.connection.copyMessages = async () => {
|
|
247
|
+
copies += 1;
|
|
248
|
+
return { uidMap: new Map() };
|
|
249
|
+
};
|
|
250
|
+
h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
|
|
251
|
+
|
|
252
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
253
|
+
|
|
254
|
+
assert.equal(copies, 1, "the COPY is issued once");
|
|
255
|
+
assert.equal(called("search").length, 0, "there is nothing to ask with");
|
|
172
256
|
const update = called("message.update")[0];
|
|
257
|
+
assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
|
|
173
258
|
assert.equal(
|
|
174
259
|
(update?.args[1] as { syncStatus?: string })?.syncStatus,
|
|
175
260
|
"failed",
|
|
176
261
|
);
|
|
177
|
-
assert.equal(called("
|
|
262
|
+
assert.equal(called("message.delete").length, 0, "no row is thrown away");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("never copies twice when a redelivered copy already landed", async () => {
|
|
266
|
+
h.destinationHolds = [42];
|
|
267
|
+
|
|
268
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
269
|
+
|
|
270
|
+
assert.equal(called("copyMessages").length, 0);
|
|
271
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
272
|
+
"new-msg",
|
|
273
|
+
42,
|
|
274
|
+
"dst-mbx",
|
|
275
|
+
]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("never copies twice when a redelivered copy cannot be probed", async () => {
|
|
279
|
+
h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
|
|
280
|
+
let copies = 0;
|
|
281
|
+
h.connection.copyMessages = async () => {
|
|
282
|
+
copies += 1;
|
|
283
|
+
// The COPY lands, then the tagged OK is lost with the connection.
|
|
284
|
+
throw new Error("connection reset by peer");
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
await assert.rejects(
|
|
288
|
+
handleMessageCopy(event, noopLog, 1, deps()),
|
|
289
|
+
/connection reset/,
|
|
290
|
+
);
|
|
291
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
292
|
+
|
|
293
|
+
assert.equal(copies, 1, "the redelivery issues no second COPY");
|
|
294
|
+
assert.equal(called("search").length, 0);
|
|
295
|
+
const settled = called("message.update").at(-1);
|
|
296
|
+
assert.equal((settled?.args[1] as { status?: string })?.status, "deleted");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("acks a copy that already settled without touching IMAP", async () => {
|
|
300
|
+
h.copyRow = {
|
|
301
|
+
...unsettledCopyRow(),
|
|
302
|
+
uid: 20,
|
|
303
|
+
status: "active",
|
|
304
|
+
syncStatus: "synced",
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
308
|
+
|
|
309
|
+
assert.equal(h.getConnectionCount, 0);
|
|
310
|
+
assert.equal(called("copyMessages").length, 0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("acks when the copy row no longer exists", async () => {
|
|
314
|
+
h.copyRow = null;
|
|
315
|
+
|
|
316
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
317
|
+
|
|
318
|
+
assert.equal(h.getConnectionCount, 0);
|
|
319
|
+
assert.equal(called("message.update").length, 0);
|
|
178
320
|
});
|
|
179
321
|
|
|
180
322
|
it("returns early without connecting when the account is soft-deleted", async () => {
|
|
@@ -184,7 +326,7 @@ describe("handleMessageCopy", () => {
|
|
|
184
326
|
deletedAt: Date.now(),
|
|
185
327
|
};
|
|
186
328
|
|
|
187
|
-
await handleMessageCopy(event, noopLog, deps());
|
|
329
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
188
330
|
|
|
189
331
|
assert.equal(h.getConnectionCount, 0);
|
|
190
332
|
});
|
|
@@ -193,7 +335,7 @@ describe("handleMessageCopy", () => {
|
|
|
193
335
|
h.account = null;
|
|
194
336
|
|
|
195
337
|
await assert.rejects(
|
|
196
|
-
handleMessageCopy(event, noopLog, deps()),
|
|
338
|
+
handleMessageCopy(event, noopLog, 1, deps()),
|
|
197
339
|
/not found/,
|
|
198
340
|
);
|
|
199
341
|
});
|
|
@@ -201,7 +343,7 @@ describe("handleMessageCopy", () => {
|
|
|
201
343
|
it("acks terminally without connecting when the source mailbox was deleted", async () => {
|
|
202
344
|
h.mailboxError = notFoundError();
|
|
203
345
|
|
|
204
|
-
await handleMessageCopy(event, noopLog, deps());
|
|
346
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
205
347
|
|
|
206
348
|
assert.equal(h.getConnectionCount, 0);
|
|
207
349
|
assert.equal(called("message.updateUid").length, 0);
|
|
@@ -215,7 +357,7 @@ describe("handleMessageCopy", () => {
|
|
|
215
357
|
cursorState: "rebuilding",
|
|
216
358
|
};
|
|
217
359
|
|
|
218
|
-
await handleMessageCopy(event, noopLog, deps());
|
|
360
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
219
361
|
|
|
220
362
|
assert.equal(h.getConnectionCount, 0);
|
|
221
363
|
assert.equal(called("message.updateUid").length, 0);
|
|
@@ -224,7 +366,7 @@ describe("handleMessageCopy", () => {
|
|
|
224
366
|
it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
225
367
|
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
226
368
|
|
|
227
|
-
await handleMessageCopy(event, noopLog, deps());
|
|
369
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
228
370
|
|
|
229
371
|
assert.equal(
|
|
230
372
|
(called("mailbox.update")[0]?.args[2] as { cursorState?: string })
|
|
@@ -242,7 +384,7 @@ describe("handleMessageCopy", () => {
|
|
|
242
384
|
};
|
|
243
385
|
|
|
244
386
|
await assert.rejects(
|
|
245
|
-
handleMessageCopy(event, noopLog, deps()),
|
|
387
|
+
handleMessageCopy(event, noopLog, 1, deps()),
|
|
246
388
|
/TRYCREATE/,
|
|
247
389
|
);
|
|
248
390
|
|
|
@@ -255,20 +397,20 @@ describe("handleMessageCopy", () => {
|
|
|
255
397
|
throw new Error("NONEXISTENT source message");
|
|
256
398
|
};
|
|
257
399
|
|
|
258
|
-
await handleMessageCopy(event, noopLog, deps());
|
|
400
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
259
401
|
|
|
260
402
|
const update = called("message.update")[0];
|
|
261
403
|
assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
|
|
262
404
|
assert.equal(called("createMailbox").length, 0);
|
|
263
405
|
});
|
|
264
406
|
|
|
265
|
-
it("marks failed and rethrows on an unclassified IMAP error", async () => {
|
|
407
|
+
it("marks failed and rethrows on an unclassified IMAP error within the budget", async () => {
|
|
266
408
|
h.connection.copyMessages = async () => {
|
|
267
409
|
throw new Error("server exploded");
|
|
268
410
|
};
|
|
269
411
|
|
|
270
412
|
await assert.rejects(
|
|
271
|
-
handleMessageCopy(event, noopLog, deps()),
|
|
413
|
+
handleMessageCopy(event, noopLog, 1, deps()),
|
|
272
414
|
/server exploded/,
|
|
273
415
|
);
|
|
274
416
|
|
|
@@ -278,4 +420,36 @@ describe("handleMessageCopy", () => {
|
|
|
278
420
|
"failed",
|
|
279
421
|
);
|
|
280
422
|
});
|
|
423
|
+
|
|
424
|
+
it("settles on the destination UID instead of dead-lettering the last attempt", async () => {
|
|
425
|
+
h.connection.copyMessages = async () => {
|
|
426
|
+
throw new Error("server exploded");
|
|
427
|
+
};
|
|
428
|
+
let asked = 0;
|
|
429
|
+
h.connection.search = async (criteria: unknown[]) => {
|
|
430
|
+
h.calls.push({ method: "search", args: criteria });
|
|
431
|
+
asked += 1;
|
|
432
|
+
return asked === 1 ? [] : [42];
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
await handleMessageCopy(event, noopLog, 3, deps());
|
|
436
|
+
|
|
437
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
438
|
+
"new-msg",
|
|
439
|
+
42,
|
|
440
|
+
"dst-mbx",
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("reconciles the row away when the last attempt finds nothing at the destination", async () => {
|
|
445
|
+
h.connection.copyMessages = async () => {
|
|
446
|
+
throw new Error("server exploded");
|
|
447
|
+
};
|
|
448
|
+
h.destinationHolds = [];
|
|
449
|
+
|
|
450
|
+
await handleMessageCopy(event, noopLog, 3, deps());
|
|
451
|
+
|
|
452
|
+
assert.equal(called("message.delete")[0]?.args[0], "new-msg");
|
|
453
|
+
assert.equal(called("threadMessage.deleteMany").length, 1);
|
|
454
|
+
});
|
|
281
455
|
});
|
|
@@ -3,8 +3,11 @@ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
|
3
3
|
import type { Logger } from "@remit/logger-lambda";
|
|
4
4
|
import {
|
|
5
5
|
guardConnectionCursor,
|
|
6
|
+
type IImapConnection,
|
|
6
7
|
isCursorRebuildNeeded,
|
|
8
|
+
isPlacementUnsettled,
|
|
7
9
|
MailboxCursorPausedError,
|
|
10
|
+
reconcileStaleMessage,
|
|
8
11
|
} from "@remit/mailbox-service";
|
|
9
12
|
import { isAccountDeleted } from "../account-check.js";
|
|
10
13
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
@@ -12,6 +15,7 @@ import type { MessageCopyEvent } from "../events.js";
|
|
|
12
15
|
import { isNotFoundError } from "../is-not-found.js";
|
|
13
16
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
14
17
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
18
|
+
import { searchMailboxByMessageId } from "./message-move.js";
|
|
15
19
|
|
|
16
20
|
export interface MessageCopyDeps {
|
|
17
21
|
getClient: typeof getClient;
|
|
@@ -27,13 +31,65 @@ const defaultDeps: MessageCopyDeps = {
|
|
|
27
31
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
28
32
|
};
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Fallback when `MESSAGE_COPY_MAX_ATTEMPTS` is unset (local dev, unit tests).
|
|
36
|
+
* Matches the `maxReceiveCount` the message queue's redrive policy uses
|
|
37
|
+
* (`remit-messages.fifo`, `deploy/vps/queues.json`), same pattern as
|
|
38
|
+
* `MESSAGE_MOVE_MAX_ATTEMPTS`.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS = 3;
|
|
41
|
+
|
|
42
|
+
export const getMessageCopyMaxAttempts = (
|
|
43
|
+
processEnv: NodeJS.ProcessEnv = process.env,
|
|
44
|
+
): number => {
|
|
45
|
+
const raw = processEnv.MESSAGE_COPY_MAX_ATTEMPTS;
|
|
46
|
+
if (!raw) return DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS;
|
|
47
|
+
const parsed = Number.parseInt(raw, 10);
|
|
48
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
49
|
+
? parsed
|
|
50
|
+
: DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const MESSAGE_COPY_MAX_ATTEMPTS = getMessageCopyMaxAttempts();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where the destination says this copy is. `unprobeable` is the row that
|
|
57
|
+
* carries no Message-ID header (`messageIdHeader` is optional on Message, and
|
|
58
|
+
* drafts and automated senders do arrive without one): the server was never
|
|
59
|
+
* asked, so its silence says nothing.
|
|
60
|
+
*/
|
|
61
|
+
export type CopyProbe =
|
|
62
|
+
| { kind: "confirmed"; uid: number }
|
|
63
|
+
| { kind: "absent" }
|
|
64
|
+
| { kind: "unprobeable" };
|
|
65
|
+
|
|
30
66
|
/**
|
|
31
67
|
* Handle MESSAGE_COPY events.
|
|
32
68
|
* Executes IMAP COPY command and updates local state with new UID.
|
|
69
|
+
*
|
|
70
|
+
* IMAP COPY is not idempotent and the message queue is FIFO per account, so
|
|
71
|
+
* this handler never manufactures a retry to resolve an ambiguous answer: a
|
|
72
|
+
* second COPY duplicates the mail, and a record that keeps coming back blocks
|
|
73
|
+
* every later move, copy and delete for that account (issues #287, #289,
|
|
74
|
+
* #290). Ambiguity is resolved in place, by asking the destination.
|
|
75
|
+
*
|
|
76
|
+
* A fault the server threw is still retried up to
|
|
77
|
+
* {@link MESSAGE_COPY_MAX_ATTEMPTS}, because it may be a dropped connection.
|
|
78
|
+
* Two guards make that safe: a redelivery asks the destination BEFORE issuing
|
|
79
|
+
* COPY again, and the last attempt settles the row instead of dead-lettering
|
|
80
|
+
* it. The copy row is a reconcile-model dependent (`copySettledMessage` writes
|
|
81
|
+
* its ThreadMessage row at `uid: 0` before the server has confirmed anything,
|
|
82
|
+
* docs/architecture/imap-mutations.md R2) and these terminal settles are its
|
|
83
|
+
* reconciliation path: every verdict the server gives an answer for settles the
|
|
84
|
+
* row out of `uid: 0`/`moving` (#1097). A server that cannot be reached at all
|
|
85
|
+
* gives no verdict — the record dead-letters with the row untouched, the same
|
|
86
|
+
* caveat `resolveExhaustedMessageMoveFailure` carries, because absence is only
|
|
87
|
+
* ever concluded from an answer.
|
|
33
88
|
*/
|
|
34
89
|
export const handleMessageCopy = async (
|
|
35
90
|
event: MessageCopyEvent,
|
|
36
91
|
log: Logger,
|
|
92
|
+
receiveCount = 1,
|
|
37
93
|
deps: MessageCopyDeps = defaultDeps,
|
|
38
94
|
): Promise<void> => {
|
|
39
95
|
const {
|
|
@@ -83,6 +139,29 @@ export const handleMessageCopy = async (
|
|
|
83
139
|
return;
|
|
84
140
|
}
|
|
85
141
|
|
|
142
|
+
const [copyRow] = await messageService.get([newMessageId]);
|
|
143
|
+
|
|
144
|
+
// The copy row is already gone — a reconciliation path settled it. Nothing
|
|
145
|
+
// left to bind a UID to; ack.
|
|
146
|
+
if (!copyRow) {
|
|
147
|
+
log.warn(
|
|
148
|
+
{ accountId, sourceMessageId, newMessageId },
|
|
149
|
+
"Skipping MESSAGE_COPY: copy row no longer exists",
|
|
150
|
+
);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// This copy already settled — `updateUid` cleared `status: moving` when the
|
|
155
|
+
// server confirmed it. A redelivery reaching here would COPY the message a
|
|
156
|
+
// second time, and COPY has no source-side effect to make that a no-op.
|
|
157
|
+
if (!isPlacementUnsettled(copyRow)) {
|
|
158
|
+
log.info(
|
|
159
|
+
{ accountId, newMessageId, uid: copyRow.uid, status: copyRow.status },
|
|
160
|
+
"Skipping MESSAGE_COPY: the copy already settled against confirmed IMAP state",
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
86
165
|
await withOAuthLifecycle(
|
|
87
166
|
buildLifecycleDeps(secrets, accountService),
|
|
88
167
|
account,
|
|
@@ -118,6 +197,131 @@ export const handleMessageCopy = async (
|
|
|
118
197
|
return;
|
|
119
198
|
}
|
|
120
199
|
|
|
200
|
+
// The copy row carries the source's Message-ID header, so the
|
|
201
|
+
// destination can be asked where this copy is. Always on the UNGUARDED
|
|
202
|
+
// handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
|
|
203
|
+
// snapshot alone, and opening the destination through it trips that
|
|
204
|
+
// mailbox into a rebuild.
|
|
205
|
+
const probeDestination = async (
|
|
206
|
+
connection: IImapConnection,
|
|
207
|
+
): Promise<CopyProbe> => {
|
|
208
|
+
if (!copyRow.messageIdHeader) return { kind: "unprobeable" };
|
|
209
|
+
const probedUid = await searchMailboxByMessageId(
|
|
210
|
+
connection,
|
|
211
|
+
destinationMailboxPath,
|
|
212
|
+
copyRow.messageIdHeader,
|
|
213
|
+
);
|
|
214
|
+
return probedUid === null
|
|
215
|
+
? { kind: "absent" }
|
|
216
|
+
: { kind: "confirmed", uid: probedUid };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const settleCopied = async (newUid: number): Promise<void> => {
|
|
220
|
+
await messageService.updateUid(
|
|
221
|
+
newMessageId,
|
|
222
|
+
newUid,
|
|
223
|
+
destinationMailboxId,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
await messageService.update(newMessageId, {
|
|
227
|
+
status: MessageStatus.active,
|
|
228
|
+
syncStatus: MessageSyncStatus.synced,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const threadMessage = await threadMessageService.findByMessageId(
|
|
232
|
+
account.accountConfigId,
|
|
233
|
+
newMessageId,
|
|
234
|
+
);
|
|
235
|
+
if (threadMessage) {
|
|
236
|
+
await threadMessageService.update(
|
|
237
|
+
threadMessage.accountConfigId,
|
|
238
|
+
threadMessage.threadMessageId,
|
|
239
|
+
{ uid: newUid },
|
|
240
|
+
{
|
|
241
|
+
composites: {
|
|
242
|
+
sentDate: threadMessage.sentDate,
|
|
243
|
+
mailboxId: threadMessage.mailboxId,
|
|
244
|
+
isRead: threadMessage.isRead,
|
|
245
|
+
isDeleted: threadMessage.isDeleted,
|
|
246
|
+
hasStars: threadMessage.hasStars,
|
|
247
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
log.info(
|
|
254
|
+
{
|
|
255
|
+
sourceMessageId,
|
|
256
|
+
newMessageId,
|
|
257
|
+
oldUid: uid,
|
|
258
|
+
newUid,
|
|
259
|
+
destination: destinationMailboxPath,
|
|
260
|
+
},
|
|
261
|
+
"Message copied successfully",
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// The destination answered that it does not hold this message, so the
|
|
266
|
+
// copy never landed and the optimistic row records something the server
|
|
267
|
+
// does not have. Deleting it (with its ThreadMessage rows) is the exact
|
|
268
|
+
// reality, the same terminal outcome `resolveExhaustedMessageMoveFailure`
|
|
269
|
+
// calls `reconciled`. Routine: metric, no alarm.
|
|
270
|
+
const settleNeverLanded = async (reason: string): Promise<void> => {
|
|
271
|
+
const { threadMessagesDeleted } = await reconcileStaleMessage(
|
|
272
|
+
{ messageService, threadMessageService },
|
|
273
|
+
account.accountConfigId,
|
|
274
|
+
newMessageId,
|
|
275
|
+
);
|
|
276
|
+
log.info(
|
|
277
|
+
{
|
|
278
|
+
metric: "message_copy_not_landed",
|
|
279
|
+
accountId,
|
|
280
|
+
sourceMessageId,
|
|
281
|
+
newMessageId,
|
|
282
|
+
destination: destinationMailboxPath,
|
|
283
|
+
threadMessagesDeleted,
|
|
284
|
+
reason,
|
|
285
|
+
},
|
|
286
|
+
"Copy absent from its destination; optimistic copy row reconciled away",
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// The server never answered where the copy is, so nothing here may
|
|
291
|
+
// delete a row that might describe real mail. The row is settled out of
|
|
292
|
+
// `moving` instead — `status: deleted` is the state this handler already
|
|
293
|
+
// writes for a copy that will not happen, and it keeps `holdsCopyOf`
|
|
294
|
+
// answering true so a sighting at the destination cannot drag the SOURCE
|
|
295
|
+
// row out of the folder it was copied from.
|
|
296
|
+
//
|
|
297
|
+
// The cost, paid deliberately: if that COPY did land, this row is the
|
|
298
|
+
// only one the copy will ever have — `holdsCopyOf` tests row existence
|
|
299
|
+
// alone (`message-sync.ts`), so no sync creates a second one — and
|
|
300
|
+
// `deleted` hides it. Real mail then sits at the destination on the
|
|
301
|
+
// server and invisible in the product until an operator acts on the
|
|
302
|
+
// alert. `settleNeverLanded` has the opposite property: it deletes only
|
|
303
|
+
// what the server denied holding, and a later sync re-projects anything
|
|
304
|
+
// that turns out to be there. Silence buys the safer half of one pair
|
|
305
|
+
// and the worse half of the other.
|
|
306
|
+
const settleBroken = async (reason: string): Promise<void> => {
|
|
307
|
+
await messageService.update(newMessageId, {
|
|
308
|
+
status: MessageStatus.deleted,
|
|
309
|
+
syncStatus: MessageSyncStatus.failed,
|
|
310
|
+
});
|
|
311
|
+
log.error(
|
|
312
|
+
{
|
|
313
|
+
alert: "message_copy_unconfirmed",
|
|
314
|
+
accountId,
|
|
315
|
+
sourceMessageId,
|
|
316
|
+
newMessageId,
|
|
317
|
+
uid,
|
|
318
|
+
destination: destinationMailboxPath,
|
|
319
|
+
reason,
|
|
320
|
+
},
|
|
321
|
+
"Copy could not be bound to a destination UID; row settled failed for operator investigation",
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
121
325
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
122
326
|
|
|
123
327
|
await scope
|
|
@@ -132,6 +336,27 @@ export const handleMessageCopy = async (
|
|
|
132
336
|
accountId,
|
|
133
337
|
mailbox,
|
|
134
338
|
);
|
|
339
|
+
|
|
340
|
+
// A redelivery means the previous attempt threw, which does not
|
|
341
|
+
// mean its COPY failed — the tagged OK can be lost with the
|
|
342
|
+
// connection. Ask before copying again, or the retry is what
|
|
343
|
+
// duplicates the mail. `absent` is an answer the server gave, so
|
|
344
|
+
// copying again is safe; `unprobeable` is silence, and copying on
|
|
345
|
+
// silence is the duplicate this guard exists to prevent.
|
|
346
|
+
if (receiveCount > 1) {
|
|
347
|
+
const earlier = await probeDestination(rawConnection);
|
|
348
|
+
if (earlier.kind === "confirmed") {
|
|
349
|
+
await settleCopied(earlier.uid);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (earlier.kind === "unprobeable") {
|
|
353
|
+
await settleBroken(
|
|
354
|
+
"redelivered with no Message-ID to ask the destination with",
|
|
355
|
+
);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
135
360
|
// Open source mailbox (read-only is fine for COPY)
|
|
136
361
|
await connection.openBox(sourceMailboxPath, true);
|
|
137
362
|
|
|
@@ -141,66 +366,30 @@ export const handleMessageCopy = async (
|
|
|
141
366
|
destinationMailboxPath,
|
|
142
367
|
);
|
|
143
368
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await messageService.updateUid(
|
|
150
|
-
newMessageId,
|
|
151
|
-
newUid,
|
|
152
|
-
destinationMailboxId,
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
// Update message status to active
|
|
156
|
-
await messageService.update(newMessageId, {
|
|
157
|
-
status: MessageStatus.active,
|
|
158
|
-
syncStatus: MessageSyncStatus.synced,
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
// Update ThreadMessage UID
|
|
162
|
-
const threadMessage = await threadMessageService.findByMessageId(
|
|
163
|
-
account.accountConfigId,
|
|
164
|
-
newMessageId,
|
|
165
|
-
);
|
|
166
|
-
if (threadMessage) {
|
|
167
|
-
await threadMessageService.update(
|
|
168
|
-
threadMessage.accountConfigId,
|
|
169
|
-
threadMessage.threadMessageId,
|
|
170
|
-
{ uid: newUid },
|
|
171
|
-
{
|
|
172
|
-
composites: {
|
|
173
|
-
sentDate: threadMessage.sentDate,
|
|
174
|
-
mailboxId: threadMessage.mailboxId,
|
|
175
|
-
isRead: threadMessage.isRead,
|
|
176
|
-
isDeleted: threadMessage.isDeleted,
|
|
177
|
-
hasStars: threadMessage.hasStars,
|
|
178
|
-
hasAttachment: threadMessage.hasAttachment,
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
);
|
|
182
|
-
}
|
|
369
|
+
const copiedUid = result.uidMap.get(uid);
|
|
370
|
+
if (copiedUid) {
|
|
371
|
+
await settleCopied(copiedUid);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
183
374
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
log.error(
|
|
197
|
-
{ sourceMessageId, uid },
|
|
198
|
-
"Source message not found in COPYUID response - may have been deleted",
|
|
375
|
+
// A server without UIDPLUS answers a perfectly successful COPY
|
|
376
|
+
// with no COPYUID entry, so an empty map is UNCONFIRMED, never
|
|
377
|
+
// evidence the server matched nothing (#1097). The destination is
|
|
378
|
+
// asked before any verdict, as `message-move.ts` does (#912, #979).
|
|
379
|
+
const probe = await probeDestination(rawConnection);
|
|
380
|
+
if (probe.kind === "confirmed") {
|
|
381
|
+
await settleCopied(probe.uid);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (probe.kind === "absent") {
|
|
385
|
+
await settleNeverLanded(
|
|
386
|
+
"no COPYUID entry, absent from destination",
|
|
199
387
|
);
|
|
200
|
-
|
|
201
|
-
syncStatus: MessageSyncStatus.failed,
|
|
202
|
-
});
|
|
388
|
+
return;
|
|
203
389
|
}
|
|
390
|
+
await settleBroken(
|
|
391
|
+
"no COPYUID entry and no Message-ID to probe with",
|
|
392
|
+
);
|
|
204
393
|
})
|
|
205
394
|
.catch(async (error: unknown) => {
|
|
206
395
|
if (error instanceof MailboxCursorPausedError) {
|
|
@@ -227,7 +416,10 @@ export const handleMessageCopy = async (
|
|
|
227
416
|
);
|
|
228
417
|
const connection = await scope.getConnection();
|
|
229
418
|
await connection.createMailbox(destinationMailboxPath);
|
|
230
|
-
// Re-throw to let the event be retried
|
|
419
|
+
// Re-throw to let the event be retried against the folder just
|
|
420
|
+
// created. Kept unconditional past the attempt budget, as the
|
|
421
|
+
// move path does: the destination now exists, so the record is
|
|
422
|
+
// worth redriving from the DLQ.
|
|
231
423
|
throw error;
|
|
232
424
|
}
|
|
233
425
|
|
|
@@ -247,11 +439,33 @@ export const handleMessageCopy = async (
|
|
|
247
439
|
return;
|
|
248
440
|
}
|
|
249
441
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
442
|
+
if (receiveCount < MESSAGE_COPY_MAX_ATTEMPTS) {
|
|
443
|
+
// Transient copy failure — expected (connections drop). Queue
|
|
444
|
+
// redelivery retries, and the probe above keeps the retry from
|
|
445
|
+
// copying the message twice.
|
|
446
|
+
await messageService.update(newMessageId, {
|
|
447
|
+
syncStatus: MessageSyncStatus.failed,
|
|
448
|
+
});
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Redelivery budget exhausted. Dead-lettering here would leave the
|
|
453
|
+
// row at `uid: 0`/`moving` with nothing left to settle it and the
|
|
454
|
+
// account's FIFO group still holding the record, so the row is
|
|
455
|
+
// resolved into one terminal outcome instead — never by inferring
|
|
456
|
+
// the server's state from our own failures. A server that cannot be
|
|
457
|
+
// reached reaches no verdict: the probe throws and the record
|
|
458
|
+
// dead-letters with the row untouched.
|
|
459
|
+
const probe = await probeDestination(await scope.getConnection());
|
|
460
|
+
if (probe.kind === "confirmed") {
|
|
461
|
+
await settleCopied(probe.uid);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (probe.kind === "absent") {
|
|
465
|
+
await settleNeverLanded(`retry exhausted: ${errorMessage}`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
await settleBroken(`retry exhausted: ${errorMessage}`);
|
|
255
469
|
})
|
|
256
470
|
.finally(() => scope.disconnect());
|
|
257
471
|
},
|
package/src/processor.ts
CHANGED
|
@@ -19,7 +19,8 @@ export const processEvent = async (
|
|
|
19
19
|
/**
|
|
20
20
|
* SQS's own delivery count for the record carrying this event (1 on first
|
|
21
21
|
* delivery). Read by SYNC_MESSAGE_BODY, PLACEMENT_MOVE_PUSH, FLAG_PUSH,
|
|
22
|
-
* APPEND_SENT_MESSAGE and
|
|
22
|
+
* APPEND_SENT_MESSAGE, MESSAGE_MOVE, MESSAGE_DELETE and MESSAGE_COPY — each
|
|
23
|
+
* knows from it when this is
|
|
23
24
|
* the last attempt before the queue's own redrive policy would DLQ the
|
|
24
25
|
* record, so it can resolve retry exhaustion into a terminal outcome
|
|
25
26
|
* (issue #1270) instead of dead-lettering blindly.
|
|
@@ -46,7 +47,7 @@ export const processEvent = async (
|
|
|
46
47
|
case "FLAG_PUSH":
|
|
47
48
|
return handleFlagPush(event, log, receiveCount);
|
|
48
49
|
case "MESSAGE_COPY":
|
|
49
|
-
return handleMessageCopy(event, log);
|
|
50
|
+
return handleMessageCopy(event, log, receiveCount);
|
|
50
51
|
case "EMPTY_TRASH":
|
|
51
52
|
return handleEmptyTrash(event, log);
|
|
52
53
|
case "APPEND_SENT_MESSAGE":
|