@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -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, APPEND_SENT_MAX_ATTEMPTS, {
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.equal(called("outboxMessage.update").length, 0);
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
- const outbox = await outboxMessageService.get(
94
- account.accountConfigId,
95
- outboxMessageId,
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 appended = false;
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
- appended = true;
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 message now lives in the IMAP Sent folder. Drop the outbox row so
165
- // the user does not see it twice in the UI (Outbox + Sent). Issue #178.
166
- //
167
- // The row goes first, and nothing that can fail may come before it. The
168
- // APPEND above has already happened; anything between it and this delete
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
- 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,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
- // Once the APPEND itself has landed the copy is in Sent whatever else
197
- // failed, so that case keeps the plain retry semantics.
198
- if (appended || receiveCount < APPEND_SENT_MAX_ATTEMPTS) throw error;
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 even if the row delete that follows
204
- // it failed, so there is nothing to settle and a retry may still run.
205
- if (appended) return;
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