@remit/imap-worker 0.0.80 → 0.0.82
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,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { beforeEach, describe, it } from "node:test";
|
|
3
3
|
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
4
|
+
import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
|
|
4
5
|
import type { AppendSentMessageEvent } from "../events.js";
|
|
5
6
|
import {
|
|
6
7
|
APPEND_SENT_MAX_ATTEMPTS,
|
|
@@ -31,6 +32,11 @@ interface Harness {
|
|
|
31
32
|
* without ever running the work. */
|
|
32
33
|
ackWithoutWork: boolean;
|
|
33
34
|
disconnectCount: number;
|
|
35
|
+
attachments: Array<{
|
|
36
|
+
filename: string;
|
|
37
|
+
contentType: string;
|
|
38
|
+
content: Buffer;
|
|
39
|
+
}>;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
let h: Harness;
|
|
@@ -65,6 +71,7 @@ const fresh = (): Harness => ({
|
|
|
65
71
|
},
|
|
66
72
|
ackWithoutWork: false,
|
|
67
73
|
disconnectCount: 0,
|
|
74
|
+
attachments: [],
|
|
68
75
|
});
|
|
69
76
|
|
|
70
77
|
const deps = (): AppendSentMessageDeps =>
|
|
@@ -83,6 +90,10 @@ const deps = (): AppendSentMessageDeps =>
|
|
|
83
90
|
},
|
|
84
91
|
outboxAttachment: {
|
|
85
92
|
discardAll: record("outboxAttachment.discardAll"),
|
|
93
|
+
contentsFor: async (...args: unknown[]) => {
|
|
94
|
+
h.calls.push({ method: "outboxAttachment.contentsFor", args });
|
|
95
|
+
return h.attachments;
|
|
96
|
+
},
|
|
86
97
|
},
|
|
87
98
|
mailboxSpecialUse: {
|
|
88
99
|
findSentMailbox: async () => h.sentMailbox,
|
|
@@ -190,6 +201,85 @@ describe("handleAppendSentMessage", () => {
|
|
|
190
201
|
]);
|
|
191
202
|
});
|
|
192
203
|
|
|
204
|
+
it("files the Sent copy with the same files the wire copy carried", async () => {
|
|
205
|
+
h.attachments = [
|
|
206
|
+
{
|
|
207
|
+
filename: "numbers.csv",
|
|
208
|
+
contentType: "text/csv",
|
|
209
|
+
content: Buffer.from("q1,q2\n1,2\n"),
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
await handleAppendSentMessage(event, noopLogger, 1, deps());
|
|
214
|
+
|
|
215
|
+
assert.deepEqual(called("outboxAttachment.contentsFor")[0]?.args, [
|
|
216
|
+
"cfg-1",
|
|
217
|
+
"acc-1",
|
|
218
|
+
"out-1",
|
|
219
|
+
]);
|
|
220
|
+
const raw = String(called("connection.append")[0]?.args[1] as Buffer);
|
|
221
|
+
assert.match(raw, /^Content-Type: multipart\/mixed;/m);
|
|
222
|
+
assert.match(
|
|
223
|
+
raw,
|
|
224
|
+
/^Content-Disposition: attachment; filename=numbers\.csv$/m,
|
|
225
|
+
);
|
|
226
|
+
assert.match(raw, /^Content-Type: text\/csv; name=numbers\.csv$/m);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const unreadable = new OutboxAttachmentUnavailableError(
|
|
230
|
+
"numbers.csv",
|
|
231
|
+
"nothing is stored at key/numbers",
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const depsWithUnreadableFile = (): AppendSentMessageDeps => {
|
|
235
|
+
const base = deps();
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
getClient: async (): Promise<BackendClient> => {
|
|
239
|
+
const client = await base.getClient();
|
|
240
|
+
client.outboxAttachment.contentsFor = async () => {
|
|
241
|
+
throw unreadable;
|
|
242
|
+
};
|
|
243
|
+
return client;
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
it("retries a copy whose files could not be read, below the budget", async () => {
|
|
249
|
+
const failing = depsWithUnreadableFile();
|
|
250
|
+
|
|
251
|
+
await assert.rejects(
|
|
252
|
+
() => handleAppendSentMessage(event, noopLogger, 1, failing),
|
|
253
|
+
unreadable,
|
|
254
|
+
);
|
|
255
|
+
assert.equal(called("connection.append").length, 0);
|
|
256
|
+
assert.deepEqual(patches(), [], "the row is left for the retry");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("settles a delivered message whose files cannot be read as sent, not filed, at the budget", async () => {
|
|
260
|
+
const failing = depsWithUnreadableFile();
|
|
261
|
+
|
|
262
|
+
await handleAppendSentMessage(
|
|
263
|
+
event,
|
|
264
|
+
noopLogger,
|
|
265
|
+
APPEND_SENT_MAX_ATTEMPTS,
|
|
266
|
+
failing,
|
|
267
|
+
);
|
|
268
|
+
assert.deepEqual(patches(), [
|
|
269
|
+
{
|
|
270
|
+
status: "unfiled",
|
|
271
|
+
lastError:
|
|
272
|
+
'Sent, but not filed: the copy for INBOX/Sent could not be built because "numbers.csv" could not be read: nothing is stored at key/numbers.',
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
assert.equal(called("connection.append").length, 0);
|
|
276
|
+
assert.equal(
|
|
277
|
+
called("outboxMessage.delete").length,
|
|
278
|
+
0,
|
|
279
|
+
"the row stays in the Outbox with its reason",
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
193
283
|
it("builds the message from the outbox row's own headers", async () => {
|
|
194
284
|
await handleAppendSentMessage(event, noopLogger, 1, deps());
|
|
195
285
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
8
8
|
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
9
9
|
import type { Logger } from "@remit/logger-lambda";
|
|
10
|
+
import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
|
|
10
11
|
import {
|
|
11
12
|
buildMailMessage,
|
|
12
13
|
renderRawMessage,
|
|
@@ -28,7 +29,9 @@ const ROW_SURVIVED_ITS_DELETE =
|
|
|
28
29
|
"Sent message was filed but its outbox row survived its delete and stays hidden until the boot-time repair";
|
|
29
30
|
|
|
30
31
|
const unfiledAppendRefused = (fullPath: string, error: unknown): string =>
|
|
31
|
-
|
|
32
|
+
error instanceof OutboxAttachmentUnavailableError
|
|
33
|
+
? `Sent, but not filed: the copy for ${fullPath} could not be built because ${error.message}.`
|
|
34
|
+
: `Sent, but not filed: the mail server refused to store a copy in ${fullPath} (${error instanceof Error ? error.message : String(error)}).`;
|
|
32
35
|
|
|
33
36
|
export const getAppendSentMaxAttempts = (
|
|
34
37
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
@@ -188,12 +191,22 @@ export const handleAppendSentMessage = async (
|
|
|
188
191
|
account,
|
|
189
192
|
log,
|
|
190
193
|
async (credentials) => {
|
|
194
|
+
// Inside the retried unit: a file that cannot be read is retried below
|
|
195
|
+
// the budget and settles `unfiled` at it, rather than dead-lettering a
|
|
196
|
+
// delivered message out of every view.
|
|
197
|
+
const attachments = await outboxAttachmentService.contentsFor(
|
|
198
|
+
account.accountConfigId,
|
|
199
|
+
accountId,
|
|
200
|
+
outboxMessageId,
|
|
201
|
+
);
|
|
191
202
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
192
203
|
|
|
193
204
|
await scope
|
|
194
205
|
.getConnection()
|
|
195
206
|
.then(async (connection) => {
|
|
196
|
-
const rawMessage = await renderRawMessage(
|
|
207
|
+
const rawMessage = await renderRawMessage(
|
|
208
|
+
buildMailMessage(outbox, attachments),
|
|
209
|
+
);
|
|
197
210
|
|
|
198
211
|
const result = await connection.append(
|
|
199
212
|
sentMailbox.fullPath,
|
|
@@ -711,6 +711,23 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
711
711
|
assert.equal(called("mailbox.transition").length, 0);
|
|
712
712
|
});
|
|
713
713
|
|
|
714
|
+
it("marks failed and acks when the server refuses the DELETE with NOPERM", async () => {
|
|
715
|
+
recordDeleteIntent();
|
|
716
|
+
h.connection.deleteMailbox = async () => {
|
|
717
|
+
throw Object.assign(new Error("Command failed"), {
|
|
718
|
+
serverResponseCode: "NOPERM",
|
|
719
|
+
responseText: "Permission denied",
|
|
720
|
+
});
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
await assert.doesNotReject(
|
|
724
|
+
processMailboxManagement(deleteEvent, noopLogger, deps()),
|
|
725
|
+
);
|
|
726
|
+
assert.equal(lastSettle().to, "failed");
|
|
727
|
+
assert.equal(lastSettle().set?.syncFailureReason, "Permission denied");
|
|
728
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
|
|
729
|
+
});
|
|
730
|
+
|
|
714
731
|
it("still marks failed and rethrows when the server fails for any other reason", async () => {
|
|
715
732
|
recordDeleteIntent();
|
|
716
733
|
h.connection.deleteMailbox = async () => {
|
|
@@ -747,6 +764,21 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
747
764
|
assert.equal(lastSettle().to, "synced");
|
|
748
765
|
});
|
|
749
766
|
|
|
767
|
+
it("marks failed with the server's reason and acks when a CREATE is refused", async () => {
|
|
768
|
+
h.connection.createMailbox = async () => {
|
|
769
|
+
throw Object.assign(new Error("Command failed"), {
|
|
770
|
+
serverResponseCode: "NOPERM",
|
|
771
|
+
responseText: "Permission denied",
|
|
772
|
+
});
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
await assert.doesNotReject(
|
|
776
|
+
processMailboxManagement(createEvent, noopLogger, deps()),
|
|
777
|
+
);
|
|
778
|
+
assert.equal(lastSettle().to, "failed");
|
|
779
|
+
assert.equal(lastSettle().set?.syncFailureReason, "Permission denied");
|
|
780
|
+
});
|
|
781
|
+
|
|
750
782
|
it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
|
|
751
783
|
h.connection.createMailbox = async () => {
|
|
752
784
|
throw Object.assign(new Error("Command failed"), {
|
|
@@ -781,6 +813,25 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
781
813
|
]);
|
|
782
814
|
});
|
|
783
815
|
|
|
816
|
+
it("marks failed with the server's reason and acks when a RENAME is refused", async () => {
|
|
817
|
+
recordRenameIntent();
|
|
818
|
+
h.connection.renameMailbox = async () => {
|
|
819
|
+
throw Object.assign(new Error("Command failed"), {
|
|
820
|
+
serverResponseCode: "CANNOT",
|
|
821
|
+
responseText: "Can't rename mailbox with its children",
|
|
822
|
+
});
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
await assert.doesNotReject(
|
|
826
|
+
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
827
|
+
);
|
|
828
|
+
assert.equal(lastSettle().to, "failed");
|
|
829
|
+
assert.equal(
|
|
830
|
+
lastSettle().set?.syncFailureReason,
|
|
831
|
+
"Can't rename mailbox with its children",
|
|
832
|
+
);
|
|
833
|
+
});
|
|
834
|
+
|
|
784
835
|
it("still refuses the intent and rethrows when a RENAME fails for any other reason", async () => {
|
|
785
836
|
recordRenameIntent();
|
|
786
837
|
h.connection.renameMailbox = async () => {
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
FolderRenameSettleError,
|
|
10
10
|
isMailboxAbsentUpstream,
|
|
11
11
|
isMailboxPresentUpstream,
|
|
12
|
+
isMailboxRefusedUpstream,
|
|
12
13
|
MailboxManagementService,
|
|
14
|
+
upstreamFailureReason,
|
|
13
15
|
} from "@remit/mailbox-service";
|
|
14
16
|
import { isAccountDeleted } from "../account-check.js";
|
|
15
17
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
@@ -47,12 +49,13 @@ const recordCreateOutcome = async (
|
|
|
47
49
|
accountId: string,
|
|
48
50
|
mailboxId: string,
|
|
49
51
|
to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
|
|
52
|
+
syncFailureReason = "",
|
|
50
53
|
): Promise<void> => {
|
|
51
54
|
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
52
55
|
from: [MailboxSyncStatus.pending],
|
|
53
56
|
wherePendingPath: null,
|
|
54
57
|
to,
|
|
55
|
-
set: {},
|
|
58
|
+
set: { syncFailureReason },
|
|
56
59
|
});
|
|
57
60
|
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
58
61
|
};
|
|
@@ -167,7 +170,15 @@ const handleCreate = async (
|
|
|
167
170
|
accountId,
|
|
168
171
|
mailboxId,
|
|
169
172
|
MailboxSyncStatus.failed,
|
|
173
|
+
upstreamFailureReason(error),
|
|
170
174
|
);
|
|
175
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
176
|
+
log.error(
|
|
177
|
+
{ accountId, mailboxId, path, intent: "create", error },
|
|
178
|
+
"Mail server refused the create",
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
171
182
|
throw error;
|
|
172
183
|
}
|
|
173
184
|
})
|
|
@@ -333,7 +344,22 @@ const handleRename = async (
|
|
|
333
344
|
mailboxId,
|
|
334
345
|
oldPath,
|
|
335
346
|
newPath,
|
|
347
|
+
upstreamFailureReason(error),
|
|
336
348
|
);
|
|
349
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
350
|
+
log.error(
|
|
351
|
+
{
|
|
352
|
+
accountId,
|
|
353
|
+
mailboxId,
|
|
354
|
+
oldPath,
|
|
355
|
+
newPath,
|
|
356
|
+
intent: "rename",
|
|
357
|
+
error,
|
|
358
|
+
},
|
|
359
|
+
"Mail server refused the rename",
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
337
363
|
throw error;
|
|
338
364
|
})
|
|
339
365
|
.finally(() => scope.disconnect());
|
|
@@ -431,7 +457,11 @@ const handleDelete = async (
|
|
|
431
457
|
// worker's backstop is unreachable from it; if something else
|
|
432
458
|
// reaches it, the honest outcome is `failed`, not `synced` —
|
|
433
459
|
// the folder was never deleted and nothing was undone.
|
|
434
|
-
await managementService.failDelete(
|
|
460
|
+
await managementService.failDelete(
|
|
461
|
+
accountId,
|
|
462
|
+
mailboxId,
|
|
463
|
+
upstreamFailureReason(error),
|
|
464
|
+
);
|
|
435
465
|
if (
|
|
436
466
|
error instanceof Error &&
|
|
437
467
|
error.message.includes("Cannot delete INBOX")
|
|
@@ -443,6 +473,13 @@ const handleDelete = async (
|
|
|
443
473
|
// Don't rethrow — no retry can make this succeed.
|
|
444
474
|
return;
|
|
445
475
|
}
|
|
476
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
477
|
+
log.error(
|
|
478
|
+
{ accountId, mailboxId, path, intent: "delete", error },
|
|
479
|
+
"Mail server refused the delete",
|
|
480
|
+
);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
446
483
|
throw error;
|
|
447
484
|
}
|
|
448
485
|
})
|