@remit/imap-worker 0.0.47 → 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,49 @@ 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
|
+
|
|
132
|
+
type BackendClient = Awaited<ReturnType<AppendSentMessageDeps["getClient"]>>;
|
|
133
|
+
|
|
134
|
+
const depsWithFailingDelete = (): AppendSentMessageDeps => {
|
|
135
|
+
const base = deps();
|
|
136
|
+
return {
|
|
137
|
+
...base,
|
|
138
|
+
getClient: async (): Promise<BackendClient> => {
|
|
139
|
+
const client = await base.getClient();
|
|
140
|
+
return {
|
|
141
|
+
...client,
|
|
142
|
+
outboxMessage: {
|
|
143
|
+
...client.outboxMessage,
|
|
144
|
+
delete: async (): Promise<void> => {
|
|
145
|
+
throw new Error("storage down");
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
};
|
|
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
|
+
|
|
128
172
|
describe("handleAppendSentMessage", () => {
|
|
129
173
|
beforeEach(() => {
|
|
130
174
|
h = fresh();
|
|
@@ -268,24 +312,138 @@ describe("handleAppendSentMessage", () => {
|
|
|
268
312
|
});
|
|
269
313
|
|
|
270
314
|
it("leaves the row alone when the APPEND landed but the delete did not", async () => {
|
|
271
|
-
const failingDeps = deps();
|
|
272
|
-
const client = await failingDeps.getClient();
|
|
273
|
-
(
|
|
274
|
-
client as unknown as { outboxMessage: { delete: () => Promise<void> } }
|
|
275
|
-
).outboxMessage.delete = async () => {
|
|
276
|
-
throw new Error("storage down");
|
|
277
|
-
};
|
|
278
|
-
|
|
279
315
|
await assert.rejects(
|
|
280
|
-
handleAppendSentMessage(event, noopLog,
|
|
281
|
-
...failingDeps,
|
|
282
|
-
getClient: async () => client,
|
|
283
|
-
} as AppendSentMessageDeps),
|
|
316
|
+
handleAppendSentMessage(event, noopLog, 1, depsWithFailingDelete()),
|
|
284
317
|
/storage down/,
|
|
285
318
|
);
|
|
286
319
|
|
|
287
320
|
// The copy is in Sent, so the message is findable — settling it as unfiled
|
|
288
321
|
// would say the opposite.
|
|
289
|
-
assert.
|
|
322
|
+
assert.deepEqual(patches(), [{ appendedUid: 55 }]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("stops redelivering a landed APPEND at the budget instead of filing another copy", async () => {
|
|
326
|
+
await handleAppendSentMessage(
|
|
327
|
+
event,
|
|
328
|
+
noopLog,
|
|
329
|
+
APPEND_SENT_MAX_ATTEMPTS,
|
|
330
|
+
depsWithFailingDelete(),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
assert.equal(called("connection.append").length, 1);
|
|
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(), []);
|
|
290
448
|
});
|
|
291
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(
|
|
175
|
-
account.accountConfigId,
|
|
176
|
-
outboxMessageId,
|
|
177
|
-
);
|
|
178
|
-
await outboxAttachmentService.discardAll(
|
|
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(
|
|
179
238
|
account.accountConfigId,
|
|
180
|
-
accountId,
|
|
181
239
|
outboxMessageId,
|
|
240
|
+
{ appendedUid },
|
|
182
241
|
);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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,16 +252,27 @@ 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
|
-
//
|
|
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.
|
|
258
|
+
if (receiveCount < APPEND_SENT_MAX_ATTEMPTS) throw error;
|
|
199
259
|
return error;
|
|
200
260
|
},
|
|
201
261
|
);
|
|
202
262
|
|
|
203
|
-
// The APPEND landed: the copy is in Sent
|
|
204
|
-
//
|
|
205
|
-
|
|
263
|
+
// The APPEND landed: the copy is in Sent whatever failed after it, so there
|
|
264
|
+
// is nothing to settle as unfiled. A row that outlives its delete holds
|
|
265
|
+
// `sent`, which every view hides, until the migrator's boot-time stranded-row
|
|
266
|
+
// repair drops it — the next container start, not sooner (#824).
|
|
267
|
+
if (isSentCopyFiled({ appendedUid })) {
|
|
268
|
+
if (failure) {
|
|
269
|
+
log.error(
|
|
270
|
+
{ accountId, outboxMessageId, reason: String(failure) },
|
|
271
|
+
ROW_SURVIVED_ITS_DELETE,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
206
276
|
|
|
207
277
|
// A terminal auth failure returns here without throwing — withOAuthLifecycle
|
|
208
278
|
// flips the account to reauth_required and ACKs the record, which without
|