@remit/imap-worker 0.0.48 → 0.0.49
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
|
@@ -66,6 +66,7 @@ const fresh = (): Harness => ({
|
|
|
66
66
|
references: ["parent@example.com"],
|
|
67
67
|
inReplyTo: "parent@example.com",
|
|
68
68
|
sentAt: 1700000000000,
|
|
69
|
+
appendedUid: 0,
|
|
69
70
|
},
|
|
70
71
|
sentMailbox: { mailboxId: "sent-mbx", fullPath: "INBOX/Sent" },
|
|
71
72
|
append: async (path, raw, flags) => {
|
|
@@ -125,6 +126,9 @@ const event: AppendSentMessageEvent = {
|
|
|
125
126
|
const called = (method: string): Call[] =>
|
|
126
127
|
h.calls.filter((c) => c.method === method);
|
|
127
128
|
|
|
129
|
+
const patches = (): unknown[] =>
|
|
130
|
+
called("outboxMessage.update").map((c) => c.args[2]);
|
|
131
|
+
|
|
128
132
|
type BackendClient = Awaited<ReturnType<AppendSentMessageDeps["getClient"]>>;
|
|
129
133
|
|
|
130
134
|
const depsWithFailingDelete = (): AppendSentMessageDeps => {
|
|
@@ -146,6 +150,25 @@ const depsWithFailingDelete = (): AppendSentMessageDeps => {
|
|
|
146
150
|
};
|
|
147
151
|
};
|
|
148
152
|
|
|
153
|
+
const depsWithFailingUpdate = (): AppendSentMessageDeps => {
|
|
154
|
+
const base = deps();
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
getClient: async (): Promise<BackendClient> => {
|
|
158
|
+
const client = await base.getClient();
|
|
159
|
+
return {
|
|
160
|
+
...client,
|
|
161
|
+
outboxMessage: {
|
|
162
|
+
...client.outboxMessage,
|
|
163
|
+
update: async (): Promise<never> => {
|
|
164
|
+
throw new Error("storage down");
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
149
172
|
describe("handleAppendSentMessage", () => {
|
|
150
173
|
beforeEach(() => {
|
|
151
174
|
h = fresh();
|
|
@@ -296,12 +319,10 @@ describe("handleAppendSentMessage", () => {
|
|
|
296
319
|
|
|
297
320
|
// The copy is in Sent, so the message is findable — settling it as unfiled
|
|
298
321
|
// would say the opposite.
|
|
299
|
-
assert.
|
|
322
|
+
assert.deepEqual(patches(), [{ appendedUid: 55 }]);
|
|
300
323
|
});
|
|
301
324
|
|
|
302
325
|
it("stops redelivering a landed APPEND at the budget instead of filing another copy", async () => {
|
|
303
|
-
// A redelivery starts from the top and appends again, so retrying past the
|
|
304
|
-
// budget files one copy per attempt in the user's Sent folder (#830).
|
|
305
326
|
await handleAppendSentMessage(
|
|
306
327
|
event,
|
|
307
328
|
noopLog,
|
|
@@ -310,6 +331,119 @@ describe("handleAppendSentMessage", () => {
|
|
|
310
331
|
);
|
|
311
332
|
|
|
312
333
|
assert.equal(called("connection.append").length, 1);
|
|
313
|
-
assert.
|
|
334
|
+
assert.deepEqual(patches(), [{ appendedUid: 55 }]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("records the uid the APPEND produced before it deletes the row", async () => {
|
|
338
|
+
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
339
|
+
|
|
340
|
+
// Order is the whole of it: a uid written after the delete is a uid a
|
|
341
|
+
// redelivery never sees, and the redelivery is what files the second copy.
|
|
342
|
+
const order = h.calls.map((c) => c.method);
|
|
343
|
+
assert.deepEqual(patches(), [{ appendedUid: 55 }]);
|
|
344
|
+
assert.ok(
|
|
345
|
+
order.indexOf("outboxMessage.update") <
|
|
346
|
+
order.indexOf("outboxMessage.delete"),
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("records a copy the server filed but named no uid for", async () => {
|
|
351
|
+
// UIDPLUS is an extension. Without it the APPEND succeeds and reports
|
|
352
|
+
// nothing, and "filed" still has to be told apart from "not filed".
|
|
353
|
+
h.append = async () => ({ uid: 0, uidValidity: 0 });
|
|
354
|
+
|
|
355
|
+
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
356
|
+
|
|
357
|
+
assert.deepEqual(patches(), [{ appendedUid: -1 }]);
|
|
358
|
+
assert.equal(called("outboxMessage.delete").length, 1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("files nothing a second time when a redelivery finds a recorded uid (#858)", async () => {
|
|
362
|
+
h.outbox = { ...h.outbox, appendedUid: 55 };
|
|
363
|
+
|
|
364
|
+
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
365
|
+
|
|
366
|
+
// The copy is already in the user's Sent folder. All this redelivery owes
|
|
367
|
+
// is the delete the last attempt could not make.
|
|
368
|
+
assert.equal(called("connection.append").length, 0);
|
|
369
|
+
assert.equal(h.disconnectCount, 0);
|
|
370
|
+
assert.deepEqual(called("outboxMessage.delete")[0]?.args, [
|
|
371
|
+
"cfg-1",
|
|
372
|
+
"out-1",
|
|
373
|
+
]);
|
|
374
|
+
assert.deepEqual(called("outboxAttachment.discardAll")[0]?.args, [
|
|
375
|
+
"cfg-1",
|
|
376
|
+
"acc-1",
|
|
377
|
+
"out-1",
|
|
378
|
+
]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("retries the delete a redelivery owes while the queue has attempts left", async () => {
|
|
382
|
+
h.outbox = { ...h.outbox, appendedUid: 55 };
|
|
383
|
+
|
|
384
|
+
await assert.rejects(
|
|
385
|
+
handleAppendSentMessage(event, noopLog, 1, depsWithFailingDelete()),
|
|
386
|
+
/storage down/,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
assert.equal(called("connection.append").length, 0);
|
|
390
|
+
assert.deepEqual(patches(), []);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("keeps the row when the uid cannot be recorded", async () => {
|
|
394
|
+
await assert.rejects(
|
|
395
|
+
handleAppendSentMessage(event, noopLog, 1, depsWithFailingUpdate()),
|
|
396
|
+
/storage down/,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// The other half of "written before the delete, never after". A row
|
|
400
|
+
// deleted without its uid is a row a redelivery reads as never filed.
|
|
401
|
+
assert.equal(called("outboxMessage.delete").length, 0);
|
|
402
|
+
assert.equal(called("outboxAttachment.discardAll").length, 0);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("acks an event whose outbox row is already gone", async () => {
|
|
406
|
+
// Its own last attempt deleted it, or the boot repair dropped it. Throwing
|
|
407
|
+
// the same NotFoundError on every redelivery only dead-letters the record.
|
|
408
|
+
const notFound = Object.assign(new Error("gone"), {
|
|
409
|
+
name: "NotFoundError",
|
|
410
|
+
});
|
|
411
|
+
const base = deps();
|
|
412
|
+
const failing: AppendSentMessageDeps = {
|
|
413
|
+
...base,
|
|
414
|
+
getClient: async (): Promise<BackendClient> => {
|
|
415
|
+
const client = await base.getClient();
|
|
416
|
+
return {
|
|
417
|
+
...client,
|
|
418
|
+
outboxMessage: {
|
|
419
|
+
...client.outboxMessage,
|
|
420
|
+
get: async () => {
|
|
421
|
+
throw notFound;
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
} as BackendClient;
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
await handleAppendSentMessage(event, noopLog, 1, failing);
|
|
429
|
+
|
|
430
|
+
assert.equal(called("connection.append").length, 0);
|
|
431
|
+
assert.deepEqual(patches(), []);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("gives up on that delete at the budget rather than dead-lettering it", async () => {
|
|
435
|
+
h.outbox = { ...h.outbox, appendedUid: 55 };
|
|
436
|
+
|
|
437
|
+
await handleAppendSentMessage(
|
|
438
|
+
event,
|
|
439
|
+
noopLog,
|
|
440
|
+
APPEND_SENT_MAX_ATTEMPTS,
|
|
441
|
+
depsWithFailingDelete(),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// The row stays `sent` and hidden, which the boot-time repair drops on the
|
|
445
|
+
// strength of the same recorded uid. Settling it as unfiled would tell the
|
|
446
|
+
// user a message sitting in Sent was never filed.
|
|
447
|
+
assert.deepEqual(patches(), []);
|
|
314
448
|
});
|
|
315
449
|
});
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import {
|
|
3
|
+
APPENDED_UID_NONE,
|
|
4
|
+
APPENDED_UID_UNREPORTED,
|
|
5
|
+
isSentCopyFiled,
|
|
6
|
+
} from "@remit/data-ports";
|
|
2
7
|
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
3
8
|
import type { Logger } from "@remit/logger-lambda";
|
|
4
9
|
import {
|
|
@@ -8,6 +13,7 @@ import {
|
|
|
8
13
|
import { isAccountDeleted } from "../account-check.js";
|
|
9
14
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
10
15
|
import type { AppendSentMessageEvent } from "../events.js";
|
|
16
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
11
17
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
12
18
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
13
19
|
|
|
@@ -17,6 +23,9 @@ const UNFILED_NO_SENT_MAILBOX =
|
|
|
17
23
|
const UNFILED_SIGNED_OUT =
|
|
18
24
|
"Sent, but not filed: this account has to be signed in again before a copy can be stored in Sent.";
|
|
19
25
|
|
|
26
|
+
const ROW_SURVIVED_ITS_DELETE =
|
|
27
|
+
"Sent message was filed but its outbox row survived its delete and stays hidden until the boot-time repair";
|
|
28
|
+
|
|
20
29
|
const unfiledAppendRefused = (fullPath: string, error: unknown): string =>
|
|
21
30
|
`Sent, but not filed: the mail server refused to store a copy in ${fullPath} (${error instanceof Error ? error.message : String(error)}).`;
|
|
22
31
|
|
|
@@ -90,10 +99,23 @@ export const handleAppendSentMessage = async (
|
|
|
90
99
|
return;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
// A row this event names can be gone by the time the event is redelivered:
|
|
103
|
+
// its own last attempt deleted it, or the boot repair dropped it on the
|
|
104
|
+
// strength of a recorded uid. Either way the work is done, and re-throwing
|
|
105
|
+
// the same NotFoundError on every redelivery only dead-letters it.
|
|
106
|
+
const outbox = await outboxMessageService
|
|
107
|
+
.get(account.accountConfigId, outboxMessageId)
|
|
108
|
+
.catch((error: unknown) => {
|
|
109
|
+
if (isNotFoundError(error)) return null;
|
|
110
|
+
throw error;
|
|
111
|
+
});
|
|
112
|
+
if (!outbox) {
|
|
113
|
+
log.warn(
|
|
114
|
+
{ accountId, outboxMessageId },
|
|
115
|
+
"Skipping APPEND_SENT_MESSAGE: the outbox row is already gone",
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
97
119
|
if (outbox.status !== OutboxMessageStatus.sent) {
|
|
98
120
|
log.info(
|
|
99
121
|
{ outboxMessageId, status: outbox.status },
|
|
@@ -121,13 +143,58 @@ export const handleAppendSentMessage = async (
|
|
|
121
143
|
);
|
|
122
144
|
};
|
|
123
145
|
|
|
146
|
+
// The copy is in Sent, so the row is a leftover and deleting it is all that
|
|
147
|
+
// is left to do. Both steps are idempotent against a row that is already
|
|
148
|
+
// gone, so nothing here needs to know how far the last attempt got.
|
|
149
|
+
//
|
|
150
|
+
// Files first, row second, the same order outbox-queue.ts discards a draft
|
|
151
|
+
// in. Nothing but this row points at those objects: a row that outlives its
|
|
152
|
+
// files is hidden and the boot repair drops it, while files that outlive
|
|
153
|
+
// their row name a message nothing can reach and are vouched for forever.
|
|
154
|
+
const dropOutboxRow = async (): Promise<void> => {
|
|
155
|
+
await outboxAttachmentService.discardAll(
|
|
156
|
+
account.accountConfigId,
|
|
157
|
+
accountId,
|
|
158
|
+
outboxMessageId,
|
|
159
|
+
);
|
|
160
|
+
await outboxMessageService.delete(account.accountConfigId, outboxMessageId);
|
|
161
|
+
log.info(
|
|
162
|
+
{ outboxMessageId },
|
|
163
|
+
"Deleted outbox row after successful APPEND to Sent",
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Everything this recovers from happened after the copy reached Sent, so
|
|
168
|
+
// re-appending is off the table and the delete is the only step left to
|
|
169
|
+
// retry. Below the redrive budget that retry is worth having; at it the
|
|
170
|
+
// record would dead-letter, so the row is left at `sent` for the repair.
|
|
171
|
+
const retryDropWithinBudget = (error: unknown): void => {
|
|
172
|
+
if (receiveCount < APPEND_SENT_MAX_ATTEMPTS) throw error;
|
|
173
|
+
log.error({ accountId, outboxMessageId, error }, ROW_SURVIVED_ITS_DELETE);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// A redelivery of an event whose APPEND already landed. The copy is in the
|
|
177
|
+
// user's Sent folder and the only step still owed is the delete that failed
|
|
178
|
+
// last time; starting from the top would file a second copy of a message the
|
|
179
|
+
// user sent once (#858). Ahead of the Sent-mailbox lookup on purpose — a
|
|
180
|
+
// message that is already filed does not need one, and an account that lost
|
|
181
|
+
// its Sent appointment in between must not settle as unfiled.
|
|
182
|
+
if (isSentCopyFiled(outbox)) {
|
|
183
|
+
log.info(
|
|
184
|
+
{ outboxMessageId, appendedUid: outbox.appendedUid },
|
|
185
|
+
"Sent copy was already filed by an earlier attempt, skipping the APPEND",
|
|
186
|
+
);
|
|
187
|
+
await dropOutboxRow().catch(retryDropWithinBudget);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
124
191
|
const sentMailbox = await mailboxSpecialUseService.findSentMailbox(accountId);
|
|
125
192
|
if (!sentMailbox) {
|
|
126
193
|
await settleUnfiled(UNFILED_NO_SENT_MAILBOX);
|
|
127
194
|
return;
|
|
128
195
|
}
|
|
129
196
|
|
|
130
|
-
let
|
|
197
|
+
let appendedUid = APPENDED_UID_NONE;
|
|
131
198
|
|
|
132
199
|
const failure = await withOAuthLifecycle(
|
|
133
200
|
buildLifecycleDeps(secrets, accountService),
|
|
@@ -157,33 +224,25 @@ export const handleAppendSentMessage = async (
|
|
|
157
224
|
"Appended sent message to Sent mailbox",
|
|
158
225
|
);
|
|
159
226
|
|
|
160
|
-
|
|
227
|
+
// A server without UIDPLUS files the copy and names no uid for
|
|
228
|
+
// it, which is still a filed copy and has to read as one.
|
|
229
|
+
appendedUid = result.uid > 0 ? result.uid : APPENDED_UID_UNREPORTED;
|
|
161
230
|
})
|
|
162
231
|
.finally(() => scope.disconnect());
|
|
163
232
|
|
|
164
|
-
// The
|
|
165
|
-
// the
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
// that throws leaves the row for the job to retry, and the retry appends
|
|
170
|
-
// a second copy to the user's Sent folder. A storage error is not worth
|
|
171
|
-
// a duplicate message — the attachment objects that outlive their row
|
|
172
|
-
// are exactly what the sweep collects, so losing this delete costs a
|
|
173
|
-
// sweep and nothing else.
|
|
174
|
-
await outboxMessageService.delete(
|
|
233
|
+
// The idempotency key, and the only reason a redelivery is safe. It is
|
|
234
|
+
// written before the delete and never after it: from here on every step
|
|
235
|
+
// can fail and be retried without the user gaining a second copy, and
|
|
236
|
+
// the one window that still can is this single row update.
|
|
237
|
+
await outboxMessageService.update(
|
|
175
238
|
account.accountConfigId,
|
|
176
239
|
outboxMessageId,
|
|
240
|
+
{ appendedUid },
|
|
177
241
|
);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
log.info(
|
|
184
|
-
{ outboxMessageId },
|
|
185
|
-
"Deleted outbox row after successful APPEND to Sent",
|
|
186
|
-
);
|
|
242
|
+
|
|
243
|
+
// The message now lives in the IMAP Sent folder. Drop the outbox row so
|
|
244
|
+
// the user does not see it twice in the UI (Outbox + Sent). Issue #178.
|
|
245
|
+
await dropOutboxRow();
|
|
187
246
|
},
|
|
188
247
|
).then(
|
|
189
248
|
() => null,
|
|
@@ -193,9 +252,9 @@ export const handleAppendSentMessage = async (
|
|
|
193
252
|
// attempt to pick up. At the budget the record would dead-letter, and a
|
|
194
253
|
// dead-lettered APPEND is exactly how a delivered message goes missing.
|
|
195
254
|
//
|
|
196
|
-
// The budget binds
|
|
197
|
-
//
|
|
198
|
-
//
|
|
255
|
+
// The budget still binds after a landed APPEND, though no longer to
|
|
256
|
+
// hold off a duplicate: the recorded uid does that, and the retries
|
|
257
|
+
// the budget allows re-drive the delete alone.
|
|
199
258
|
if (receiveCount < APPEND_SENT_MAX_ATTEMPTS) throw error;
|
|
200
259
|
return error;
|
|
201
260
|
},
|
|
@@ -204,12 +263,12 @@ export const handleAppendSentMessage = async (
|
|
|
204
263
|
// The APPEND landed: the copy is in Sent whatever failed after it, so there
|
|
205
264
|
// is nothing to settle as unfiled. A row that outlives its delete holds
|
|
206
265
|
// `sent`, which every view hides, until the migrator's boot-time stranded-row
|
|
207
|
-
// repair
|
|
208
|
-
if (
|
|
266
|
+
// repair drops it — the next container start, not sooner (#824).
|
|
267
|
+
if (isSentCopyFiled({ appendedUid })) {
|
|
209
268
|
if (failure) {
|
|
210
269
|
log.error(
|
|
211
270
|
{ accountId, outboxMessageId, reason: String(failure) },
|
|
212
|
-
|
|
271
|
+
ROW_SURVIVED_ITS_DELETE,
|
|
213
272
|
);
|
|
214
273
|
}
|
|
215
274
|
return;
|