@remit/imap-worker 0.0.86 → 0.0.87
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/README.md +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.ts +1 -1
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +27 -33
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +16 -18
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +192 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -569
- package/src/scheduler/attachment-sweep.test.ts +0 -169
- package/src/scheduler/run-tick.test.ts +0 -478
- package/src/with-oauth-lifecycle.test.ts +0 -318
package/README.md
CHANGED
|
@@ -48,7 +48,9 @@ npm run cli -- -t SYNC_MESSAGE_BODY -a <accountId> -m <mailboxId> --messageIds i
|
|
|
48
48
|
| Variable | Required | Description |
|
|
49
49
|
| ----------------------------- | -------- | -------------------------------------------------------- |
|
|
50
50
|
| `DATA_BACKEND` | Yes | Relational backend to run against (`sqlite`) |
|
|
51
|
-
| `
|
|
51
|
+
| `SQLITE_APP_DB_PATH` | Yes | Path to the app SQLite database |
|
|
52
|
+
| `SQLITE_MAIL_DB_PATH` | Yes | Path to the tenant mail SQLite database |
|
|
53
|
+
| `SQLITE_SEARCH_DB_PATH` | Yes | Path to the tenant search SQLite database |
|
|
52
54
|
| `SQS_QUEUE_URL_MAILBOXES` | Yes | FIFO queue URL for `SYNC_MAILBOXES` events |
|
|
53
55
|
| `SQS_QUEUE_URL_MESSAGES` | Yes | FIFO queue URL for `SYNC_MESSAGES` events |
|
|
54
56
|
| `SQS_QUEUE_URL_BODY` | Yes | FIFO queue URL for `SYNC_MESSAGE_BODY` events |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/imap-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.87",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
"./scheduler": {
|
|
12
12
|
"types": "./src/scheduler/handler.ts",
|
|
13
13
|
"default": "./src/scheduler/handler.ts"
|
|
14
|
+
},
|
|
15
|
+
"./mailbox-management": {
|
|
16
|
+
"types": "./src/handlers/mailbox-management.ts",
|
|
17
|
+
"default": "./src/handlers/mailbox-management.ts"
|
|
18
|
+
},
|
|
19
|
+
"./test-helpers/test-world": {
|
|
20
|
+
"types": "./src/test-helpers/test-world.ts",
|
|
21
|
+
"default": "./src/test-helpers/test-world.ts"
|
|
14
22
|
}
|
|
15
23
|
},
|
|
16
24
|
"bin": {
|
|
@@ -32,13 +40,13 @@
|
|
|
32
40
|
"esbuild": "^0.28.1"
|
|
33
41
|
},
|
|
34
42
|
"dependencies": {
|
|
43
|
+
"@remit/logger": "*",
|
|
35
44
|
"@aws-sdk/client-s3": "^3.1055.0",
|
|
36
45
|
"@remit/config-transfer": "*",
|
|
37
46
|
"@remit/data-ports": "*",
|
|
38
47
|
"@remit/smtp-service": "*",
|
|
39
48
|
"@remit/logger-lambda": "*",
|
|
40
49
|
"@remit/mailbox-service": "*",
|
|
41
|
-
"@remit/search-index-worker": "*",
|
|
42
50
|
"@remit/sqs-client": "*",
|
|
43
51
|
"@remit/backend": "*",
|
|
44
52
|
"@remit/calendar-service": "*",
|
package/src/account-check.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AccountItem } from "@remit/data-ports";
|
|
2
2
|
import { AccountService } from "@remit/domain-enums";
|
|
3
|
-
import type { Logger } from "@remit/logger
|
|
3
|
+
import type { Logger } from "@remit/logger";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* RFC 2606 reserved placeholder namespaces that are guaranteed never to resolve
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
ParameterNotFound,
|
|
6
6
|
SSMClient,
|
|
7
7
|
} from "@aws-sdk/client-ssm";
|
|
8
|
-
import type { Logger } from "@remit/logger
|
|
8
|
+
import type { Logger } from "@remit/logger";
|
|
9
9
|
import { mockClient } from "aws-sdk-client-mock";
|
|
10
10
|
import { isBodySyncEnabled, resetBodySyncGateCache } from "./body-sync-gate.js";
|
|
11
11
|
|
package/src/body-sync-gate.ts
CHANGED
|
@@ -5,18 +5,15 @@ import {
|
|
|
5
5
|
DeleteMessageCommand,
|
|
6
6
|
ReceiveMessageCommand,
|
|
7
7
|
} from "@aws-sdk/client-sqs";
|
|
8
|
-
import { createLogger } from "@remit/logger
|
|
9
|
-
import {
|
|
8
|
+
import { createLogger } from "@remit/logger";
|
|
9
|
+
import { installNodeLogger } from "@remit/logger-lambda/node";
|
|
10
10
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
11
|
-
import type {
|
|
12
|
-
Context,
|
|
13
|
-
SQSBatchResponse,
|
|
14
|
-
SQSEvent,
|
|
15
|
-
SQSHandler,
|
|
16
|
-
} from "aws-lambda";
|
|
11
|
+
import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda";
|
|
17
12
|
import { env } from "expect-env";
|
|
18
13
|
import { handler } from "./index.js";
|
|
19
14
|
|
|
15
|
+
installNodeLogger();
|
|
16
|
+
|
|
20
17
|
/**
|
|
21
18
|
* E2E-only queue drainer.
|
|
22
19
|
*
|
|
@@ -34,11 +31,6 @@ import { handler } from "./index.js";
|
|
|
34
31
|
* fails loudly, which is the desired signal — there is no crash net.
|
|
35
32
|
*/
|
|
36
33
|
|
|
37
|
-
// The search-index queue is drained by the production search-index-worker
|
|
38
|
-
// handler instead of the imap handler. It is optional: when its URL is unset
|
|
39
|
-
// (e.g. the e2e stack), the queue is simply not polled.
|
|
40
|
-
const searchIndexQueueUrl = process.env.SQS_QUEUE_URL_SEARCH_INDEX;
|
|
41
|
-
|
|
42
34
|
// Collect all unique queue URLs to poll. Every required queue URL crashes at
|
|
43
35
|
// init via expect-env instead of silently dropping queues.
|
|
44
36
|
const queueUrls = [
|
|
@@ -51,8 +43,6 @@ const queueUrls = [
|
|
|
51
43
|
env.SQS_QUEUE_URL_BODY,
|
|
52
44
|
env.SQS_QUEUE_URL_MAILBOX_MGMT,
|
|
53
45
|
env.SQS_QUEUE_URL_MESSAGE_MGMT,
|
|
54
|
-
// Standard queue for local search indexing (optional)
|
|
55
|
-
...(searchIndexQueueUrl ? [searchIndexQueueUrl] : []),
|
|
56
46
|
]),
|
|
57
47
|
];
|
|
58
48
|
|
|
@@ -109,11 +99,6 @@ if (cluster.isPrimary) {
|
|
|
109
99
|
const queueName = new URL(queueUrl).pathname.split("/").pop();
|
|
110
100
|
const log = createLogger().child({ queue: queueName });
|
|
111
101
|
|
|
112
|
-
// The search-index queue is owned by the search-index-worker handler; every
|
|
113
|
-
// other queue is an imap operation.
|
|
114
|
-
const activeHandler: SQSHandler =
|
|
115
|
-
queueUrl === searchIndexQueueUrl ? searchIndexHandler : handler;
|
|
116
|
-
|
|
117
102
|
const maxMessages = 10; // SQS API limit
|
|
118
103
|
|
|
119
104
|
const sqs = createQueueProducer({ queueUrl });
|
|
@@ -206,7 +191,7 @@ if (cluster.isPrimary) {
|
|
|
206
191
|
})),
|
|
207
192
|
};
|
|
208
193
|
|
|
209
|
-
const result = (await
|
|
194
|
+
const result = (await handler(event, nextContext(), () => {})) as
|
|
210
195
|
| SQSBatchResponse
|
|
211
196
|
| undefined;
|
|
212
197
|
|
package/src/emit.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { SendMessageCommand } from "@aws-sdk/client-sqs";
|
|
3
3
|
import { getClient } from "@remit/backend/client";
|
|
4
|
-
import { recordImapFailure } from "@remit/logger-lambda";
|
|
4
|
+
import { recordImapFailure } from "@remit/logger-lambda/metrics";
|
|
5
5
|
import {
|
|
6
6
|
createQueueProducer,
|
|
7
7
|
isLocalEndpoint,
|
package/src/failure-kind.ts
CHANGED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, it } from "node:test";
|
|
3
|
+
import type { OutboxMessageItem } from "@remit/data-ports";
|
|
4
|
+
import { MailboxSpecialUse, OutboxMessageStatus } from "@remit/domain-enums";
|
|
5
|
+
import type { AppendSentMessageEvent } from "../events.js";
|
|
6
|
+
import { openStore, type World } from "../test-helpers/test-world.js";
|
|
7
|
+
import { handleAppendSentMessage } from "./append-sent-message.js";
|
|
8
|
+
|
|
9
|
+
let store: Awaited<ReturnType<typeof openStore>>;
|
|
10
|
+
|
|
11
|
+
before(async () => {
|
|
12
|
+
store = await openStore();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
after(() => store.close());
|
|
16
|
+
|
|
17
|
+
interface Arranged {
|
|
18
|
+
w: World;
|
|
19
|
+
outbox: OutboxMessageItem;
|
|
20
|
+
event: AppendSentMessageEvent;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A message composed and sent through the service the API calls, then marked
|
|
25
|
+
* sent the way the SMTP worker marks it once the relay accepted it.
|
|
26
|
+
*/
|
|
27
|
+
const arrange = async (
|
|
28
|
+
options: { sentFolder?: boolean; attachment?: Buffer } = {},
|
|
29
|
+
): Promise<Arranged> => {
|
|
30
|
+
const w = await store.world();
|
|
31
|
+
await w.folder("INBOX");
|
|
32
|
+
if (options.sentFolder !== false) {
|
|
33
|
+
await w.folder("Sent", { specialUse: MailboxSpecialUse.Sent });
|
|
34
|
+
}
|
|
35
|
+
const { outbox: draft } = await w.draft(options.attachment);
|
|
36
|
+
const outbox = await w.client.outboxQueue.send(
|
|
37
|
+
w.account.accountConfigId,
|
|
38
|
+
draft.outboxMessageId,
|
|
39
|
+
);
|
|
40
|
+
await w.client.outboxMessage.update(
|
|
41
|
+
w.account.accountConfigId,
|
|
42
|
+
outbox.outboxMessageId,
|
|
43
|
+
{ status: OutboxMessageStatus.sent, sentAt: Date.now() },
|
|
44
|
+
);
|
|
45
|
+
return {
|
|
46
|
+
w,
|
|
47
|
+
outbox,
|
|
48
|
+
event: {
|
|
49
|
+
type: "APPEND_SENT_MESSAGE",
|
|
50
|
+
eventId: `evt-${outbox.outboxMessageId}`,
|
|
51
|
+
timestamp: Date.now(),
|
|
52
|
+
accountId: w.account.accountId,
|
|
53
|
+
outboxMessageId: outbox.outboxMessageId,
|
|
54
|
+
} as AppendSentMessageEvent,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const run = (a: Arranged, receiveCount = 1) =>
|
|
59
|
+
handleAppendSentMessage(a.event, a.w.log, receiveCount, a.w.scope);
|
|
60
|
+
|
|
61
|
+
const outboxRow = (a: Arranged) =>
|
|
62
|
+
a.w.client.outboxMessage
|
|
63
|
+
.get(a.w.account.accountConfigId, a.outbox.outboxMessageId)
|
|
64
|
+
.catch(() => undefined);
|
|
65
|
+
|
|
66
|
+
describe("filing a sent message", () => {
|
|
67
|
+
it("stores a seen copy under its own Message-ID in Sent and drops the outbox row", async () => {
|
|
68
|
+
const a = await arrange();
|
|
69
|
+
|
|
70
|
+
await run(a);
|
|
71
|
+
|
|
72
|
+
const [copy] = a.w.imap.messages("Sent");
|
|
73
|
+
assert.equal(copy?.messageIdHeader, `<${a.outbox.messageIdValue}>`);
|
|
74
|
+
assert.ok(copy?.flags.includes("\\Seen"));
|
|
75
|
+
assert.match(
|
|
76
|
+
copy?.raw.toString("utf8") ?? "",
|
|
77
|
+
/Subject: Quarterly numbers/,
|
|
78
|
+
);
|
|
79
|
+
assert.equal(await outboxRow(a), undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// #679: the Sent copy carries the files the wire copy carried, and the row
|
|
83
|
+
// was the only reference to them, so they go with it.
|
|
84
|
+
it("files the copy with its attachment and removes the stored file", async () => {
|
|
85
|
+
const a = await arrange({ attachment: Buffer.from("q1,q2\n1,2\n") });
|
|
86
|
+
|
|
87
|
+
await run(a);
|
|
88
|
+
|
|
89
|
+
const raw = a.w.imap.messages("Sent")[0]?.raw.toString("utf8") ?? "";
|
|
90
|
+
assert.match(raw, /^Content-Type: multipart\/mixed;/m);
|
|
91
|
+
assert.match(raw, /filename=numbers\.csv/);
|
|
92
|
+
assert.deepEqual(
|
|
93
|
+
await a.w.client.storage.listOutboxAttachments(
|
|
94
|
+
a.w.account.accountConfigId,
|
|
95
|
+
a.w.account.accountId,
|
|
96
|
+
a.outbox.outboxMessageId,
|
|
97
|
+
),
|
|
98
|
+
[],
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("settles the row as unfiled when the account has no Sent folder", async () => {
|
|
103
|
+
const a = await arrange({ sentFolder: false });
|
|
104
|
+
|
|
105
|
+
await run(a);
|
|
106
|
+
|
|
107
|
+
assert.equal(a.w.imap.connections, 0);
|
|
108
|
+
const row = await outboxRow(a);
|
|
109
|
+
assert.equal(row?.status, OutboxMessageStatus.unfiled);
|
|
110
|
+
assert.match(
|
|
111
|
+
String(row?.lastError),
|
|
112
|
+
/no folder appointed to the Sent role/,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("files nothing while the message has not been sent yet", async () => {
|
|
117
|
+
const a = await arrange();
|
|
118
|
+
await a.w.client.outboxMessage.update(
|
|
119
|
+
a.w.account.accountConfigId,
|
|
120
|
+
a.outbox.outboxMessageId,
|
|
121
|
+
{ status: OutboxMessageStatus.queued },
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
await run(a);
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(a.w.imap.commands("APPEND"), []);
|
|
127
|
+
assert.equal((await outboxRow(a))?.status, OutboxMessageStatus.queued);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("keeps the row and redelivers a refused APPEND within the budget", async () => {
|
|
131
|
+
const a = await arrange();
|
|
132
|
+
a.w.imap.failNext.APPEND = new Error("Connection reset");
|
|
133
|
+
|
|
134
|
+
await assert.rejects(run(a), /Connection reset/);
|
|
135
|
+
|
|
136
|
+
assert.equal((await outboxRow(a))?.status, OutboxMessageStatus.sent);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("settles the row as unfiled on the last attempt instead of dead-lettering it", async () => {
|
|
140
|
+
const a = await arrange();
|
|
141
|
+
a.w.imap.failNext.APPEND = new Error("Mailbox is full");
|
|
142
|
+
|
|
143
|
+
await run(a, 3);
|
|
144
|
+
|
|
145
|
+
const row = await outboxRow(a);
|
|
146
|
+
assert.equal(row?.status, OutboxMessageStatus.unfiled);
|
|
147
|
+
assert.match(String(row?.lastError), /Mailbox is full/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// #858: a redelivery after the APPEND landed must not file a second copy.
|
|
151
|
+
it("files nothing a second time when the copy was already recorded", async () => {
|
|
152
|
+
const a = await arrange();
|
|
153
|
+
await a.w.client.outboxMessage.update(
|
|
154
|
+
a.w.account.accountConfigId,
|
|
155
|
+
a.outbox.outboxMessageId,
|
|
156
|
+
{ appendedUid: 41 },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await run(a);
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(a.w.imap.commands("APPEND"), []);
|
|
162
|
+
assert.equal(await outboxRow(a), undefined);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("acks an event whose outbox row is already gone", async () => {
|
|
166
|
+
const a = await arrange();
|
|
167
|
+
await a.w.client.outboxMessage.delete(
|
|
168
|
+
a.w.account.accountConfigId,
|
|
169
|
+
a.outbox.outboxMessageId,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await run(a);
|
|
173
|
+
|
|
174
|
+
assert.equal(a.w.imap.connections, 0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
7
|
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
8
8
|
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
9
|
-
import type { Logger } from "@remit/logger
|
|
9
|
+
import type { Logger } from "@remit/logger";
|
|
10
10
|
import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
|
|
11
11
|
import {
|
|
12
12
|
buildMailMessage,
|
|
@@ -39,20 +39,6 @@ export const getAppendSentMaxAttempts = (
|
|
|
39
39
|
|
|
40
40
|
export const APPEND_SENT_MAX_ATTEMPTS = getAppendSentMaxAttempts();
|
|
41
41
|
|
|
42
|
-
export interface AppendSentMessageDeps {
|
|
43
|
-
getClient: typeof getClient;
|
|
44
|
-
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
45
|
-
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
46
|
-
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const defaultDeps: AppendSentMessageDeps = {
|
|
50
|
-
getClient,
|
|
51
|
-
buildLifecycleDeps,
|
|
52
|
-
withOAuthLifecycle,
|
|
53
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
42
|
export const handleAppendSentMessage = async (
|
|
57
43
|
event: AppendSentMessageEvent,
|
|
58
44
|
log: Logger,
|
|
@@ -63,15 +49,8 @@ export const handleAppendSentMessage = async (
|
|
|
63
49
|
* leaves the row at `sent`, which no view shows.
|
|
64
50
|
*/
|
|
65
51
|
receiveCount = 1,
|
|
66
|
-
|
|
52
|
+
createConnectionScope = createConnectionScopeWithCredentials,
|
|
67
53
|
): Promise<void> => {
|
|
68
|
-
const {
|
|
69
|
-
getClient,
|
|
70
|
-
buildLifecycleDeps,
|
|
71
|
-
withOAuthLifecycle,
|
|
72
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
73
|
-
} = deps;
|
|
74
|
-
|
|
75
54
|
const {
|
|
76
55
|
account: accountService,
|
|
77
56
|
outboxMessage: outboxMessageService,
|
|
@@ -199,7 +178,7 @@ export const handleAppendSentMessage = async (
|
|
|
199
178
|
accountId,
|
|
200
179
|
outboxMessageId,
|
|
201
180
|
);
|
|
202
|
-
const scope =
|
|
181
|
+
const scope = createConnectionScope(account, credentials);
|
|
203
182
|
|
|
204
183
|
await scope
|
|
205
184
|
.getConnection()
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
S3Client,
|
|
7
7
|
} from "@aws-sdk/client-s3";
|
|
8
8
|
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
9
|
-
import { noopLogger } from "@remit/logger
|
|
9
|
+
import { noopLogger } from "@remit/logger";
|
|
10
10
|
import { mockClient } from "aws-sdk-client-mock";
|
|
11
11
|
import type { DeleteAccountObjectsEvent } from "../events.js";
|
|
12
12
|
import { handleDeleteAccountObjects } from "./delete-account-objects.js";
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
S3Client,
|
|
5
5
|
} from "@aws-sdk/client-s3";
|
|
6
6
|
import { SendMessageCommand } from "@aws-sdk/client-sqs";
|
|
7
|
-
import type { Logger } from "@remit/logger
|
|
7
|
+
import type { Logger } from "@remit/logger";
|
|
8
8
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
9
9
|
import { env } from "expect-env";
|
|
10
10
|
import type { DeleteAccountObjectsEvent } from "../events.js";
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, it } from "node:test";
|
|
3
|
+
import type { MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
MailboxCursorState,
|
|
6
|
+
MailboxSpecialUse,
|
|
7
|
+
MessageStatus,
|
|
8
|
+
} from "@remit/domain-enums";
|
|
9
|
+
import type { EmptyTrashEvent } from "../events.js";
|
|
10
|
+
import {
|
|
11
|
+
openStore,
|
|
12
|
+
type SeededMessage,
|
|
13
|
+
type World,
|
|
14
|
+
} from "../test-helpers/test-world.js";
|
|
15
|
+
import { handleEmptyTrash } from "./empty-trash.js";
|
|
16
|
+
|
|
17
|
+
let store: Awaited<ReturnType<typeof openStore>>;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
store = await openStore();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
after(() => store.close());
|
|
24
|
+
|
|
25
|
+
interface Arranged {
|
|
26
|
+
w: World;
|
|
27
|
+
inbox: MailboxItem;
|
|
28
|
+
trash: MailboxItem;
|
|
29
|
+
trashed: SeededMessage[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const arrange = async (count = 2): Promise<Arranged> => {
|
|
33
|
+
const w = await store.world();
|
|
34
|
+
const inbox = await w.folder("INBOX");
|
|
35
|
+
const trash = await w.folder("Trash", {
|
|
36
|
+
specialUse: MailboxSpecialUse.Trash,
|
|
37
|
+
});
|
|
38
|
+
const trashed: SeededMessage[] = [];
|
|
39
|
+
for (let i = 0; i < count; i += 1) trashed.push(await w.message(trash));
|
|
40
|
+
return { w, inbox, trash, trashed };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Empty Trash through the service the API calls, and hand back its event. */
|
|
44
|
+
const emptyTrash = async (a: Arranged): Promise<EmptyTrashEvent> => {
|
|
45
|
+
await a.w.client.messageMove.emptyTrash(
|
|
46
|
+
a.w.account.accountConfigId,
|
|
47
|
+
a.w.account.accountId,
|
|
48
|
+
);
|
|
49
|
+
const event = a.w
|
|
50
|
+
.queued()
|
|
51
|
+
.find((body) => body.type === "EMPTY_TRASH") as unknown as EmptyTrashEvent;
|
|
52
|
+
assert.ok(event, "Empty Trash enqueued an EMPTY_TRASH");
|
|
53
|
+
return event;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const run = (a: Arranged, event: EmptyTrashEvent) =>
|
|
57
|
+
handleEmptyTrash(event, a.w.log, a.w.scope);
|
|
58
|
+
|
|
59
|
+
const visible = async (a: Arranged): Promise<boolean[]> =>
|
|
60
|
+
Promise.all(
|
|
61
|
+
a.trashed.map(async (message) => {
|
|
62
|
+
const row = await a.w.row(message.messageId);
|
|
63
|
+
const listing = await a.w.threadRows(message.messageId);
|
|
64
|
+
return (
|
|
65
|
+
row?.status === MessageStatus.active &&
|
|
66
|
+
listing.length > 0 &&
|
|
67
|
+
listing.every((tm) => !tm.isDeleted)
|
|
68
|
+
);
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
describe("Empty Trash", () => {
|
|
73
|
+
it("expunges every message on the server and removes both local rows", async () => {
|
|
74
|
+
const a = await arrange();
|
|
75
|
+
const event = await emptyTrash(a);
|
|
76
|
+
|
|
77
|
+
await run(a, event);
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(a.w.imap.uids("Trash"), []);
|
|
80
|
+
for (const message of a.trashed) {
|
|
81
|
+
assert.equal(await a.w.row(message.messageId), undefined);
|
|
82
|
+
assert.deepEqual(await a.w.threadRows(message.messageId), []);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("hands every row back when another client emptied the Trash first", async () => {
|
|
87
|
+
const a = await arrange();
|
|
88
|
+
const event = await emptyTrash(a);
|
|
89
|
+
a.w.imap.empty("Trash");
|
|
90
|
+
|
|
91
|
+
await run(a, event);
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(await visible(a), [true, true]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("keeps the row of a message the expunge never reached", async () => {
|
|
97
|
+
const a = await arrange();
|
|
98
|
+
const event = await emptyTrash(a);
|
|
99
|
+
const [kept, gone] = a.trashed;
|
|
100
|
+
a.w.imap.expunge("Trash", kept?.uid as number);
|
|
101
|
+
|
|
102
|
+
await run(a, event);
|
|
103
|
+
|
|
104
|
+
assert.equal(await a.w.row(gone?.messageId ?? ""), undefined);
|
|
105
|
+
assert.equal(
|
|
106
|
+
(await a.w.row(kept?.messageId ?? ""))?.status,
|
|
107
|
+
MessageStatus.active,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// A move into Trash that has not settled names Trash while still carrying
|
|
112
|
+
// INBOX's uid. Matching it against the expunge would delete whatever Trash
|
|
113
|
+
// holds at that number.
|
|
114
|
+
it("leaves a message whose move into Trash has not settled", async () => {
|
|
115
|
+
const a = await arrange(1);
|
|
116
|
+
const moving = await a.w.message(a.inbox);
|
|
117
|
+
await a.w.client.messageMove.deleteMessage(
|
|
118
|
+
a.w.account.accountConfigId,
|
|
119
|
+
moving.messageId,
|
|
120
|
+
a.w.account.accountId,
|
|
121
|
+
);
|
|
122
|
+
const event = await emptyTrash(a);
|
|
123
|
+
|
|
124
|
+
await run(a, event);
|
|
125
|
+
|
|
126
|
+
const row = await a.w.row(moving.messageId);
|
|
127
|
+
assert.equal(row?.status, MessageStatus.moving);
|
|
128
|
+
assert.deepEqual(a.w.imap.uids("INBOX"), [moving.uid]);
|
|
129
|
+
assert.equal(await a.w.row(a.trashed[0]?.messageId ?? ""), undefined);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("expunges nothing and hands the rows back when the folder at the path is not the one emptied", async () => {
|
|
133
|
+
const a = await arrange();
|
|
134
|
+
const event = await emptyTrash(a);
|
|
135
|
+
a.w.imap.setUidValidity("Trash", 99);
|
|
136
|
+
|
|
137
|
+
await run(a, event);
|
|
138
|
+
|
|
139
|
+
assert.deepEqual(a.w.imap.commands("EXPUNGE"), []);
|
|
140
|
+
assert.deepEqual(await visible(a), [true, true]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("expunges nothing when the Trash role no longer rests on confirmed evidence", async () => {
|
|
144
|
+
const a = await arrange();
|
|
145
|
+
const event = await emptyTrash(a);
|
|
146
|
+
await a.w.client.mailboxSpecialUse.deleteByMailboxId(a.trash.mailboxId);
|
|
147
|
+
|
|
148
|
+
await run(a, event);
|
|
149
|
+
|
|
150
|
+
assert.equal(a.w.imap.connections, 0);
|
|
151
|
+
assert.deepEqual(await visible(a), [true, true]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("hands the rows back without connecting while the cursor is rebuilding", async () => {
|
|
155
|
+
const a = await arrange();
|
|
156
|
+
const event = await emptyTrash(a);
|
|
157
|
+
await a.w.client.mailbox.update(a.w.account.accountId, a.trash.mailboxId, {
|
|
158
|
+
cursorState: MailboxCursorState.rebuilding,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await run(a, event);
|
|
162
|
+
|
|
163
|
+
assert.equal(a.w.imap.connections, 0);
|
|
164
|
+
assert.deepEqual(await visible(a), [true, true]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("refuses an event minted under an unknown contract and hands the rows back", async () => {
|
|
168
|
+
const a = await arrange();
|
|
169
|
+
const event = await emptyTrash(a);
|
|
170
|
+
|
|
171
|
+
await run(a, { ...event, schemaVersion: 99 } as unknown as EmptyTrashEvent);
|
|
172
|
+
|
|
173
|
+
assert.equal(a.w.imap.connections, 0);
|
|
174
|
+
assert.deepEqual(await visible(a), [true, true]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("acks without connecting when the Trash mailbox was deleted", async () => {
|
|
178
|
+
const a = await arrange();
|
|
179
|
+
const event = await emptyTrash(a);
|
|
180
|
+
await a.w.client.mailbox.delete(a.w.account.accountId, a.trash.mailboxId);
|
|
181
|
+
|
|
182
|
+
await run(a, event);
|
|
183
|
+
|
|
184
|
+
assert.equal(a.w.imap.connections, 0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("empties Trash under its current path when the folder was renamed after enqueue", async () => {
|
|
188
|
+
const a = await arrange();
|
|
189
|
+
const event = await emptyTrash(a);
|
|
190
|
+
a.w.imap.renameFolder("Trash", "Deleted Items");
|
|
191
|
+
await a.w.client.mailbox.update(a.w.account.accountId, a.trash.mailboxId, {
|
|
192
|
+
fullPath: "Deleted Items",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await run(a, event);
|
|
196
|
+
|
|
197
|
+
assert.deepEqual(a.w.imap.uids("Deleted Items"), []);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("rethrows a failed expunge so the event is retried", async () => {
|
|
201
|
+
const a = await arrange();
|
|
202
|
+
const event = await emptyTrash(a);
|
|
203
|
+
a.w.imap.failNext.EXPUNGE = new Error("Connection reset");
|
|
204
|
+
|
|
205
|
+
await assert.rejects(run(a, event), /Connection reset/);
|
|
206
|
+
|
|
207
|
+
assert.equal(a.w.imap.uids("Trash").length, 2);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -3,7 +3,7 @@ import type { MessageItem } from "@remit/data-ports";
|
|
|
3
3
|
import { trashMailboxAt } from "@remit/data-ports/folder-role";
|
|
4
4
|
import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
5
5
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
6
|
-
import type { Logger } from "@remit/logger
|
|
6
|
+
import type { Logger } from "@remit/logger";
|
|
7
7
|
import {
|
|
8
8
|
buildThreadMessageUndelete,
|
|
9
9
|
carriesForeignUid,
|
|
@@ -18,20 +18,6 @@ import { findMailboxRow } from "../mailbox-row.js";
|
|
|
18
18
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
19
19
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
20
20
|
|
|
21
|
-
export interface EmptyTrashDeps {
|
|
22
|
-
getClient: typeof getClient;
|
|
23
|
-
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
24
|
-
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
25
|
-
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const defaultDeps: EmptyTrashDeps = {
|
|
29
|
-
getClient,
|
|
30
|
-
buildLifecycleDeps,
|
|
31
|
-
withOAuthLifecycle,
|
|
32
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
33
|
-
};
|
|
34
|
-
|
|
35
21
|
/**
|
|
36
22
|
* Handle EMPTY_TRASH events.
|
|
37
23
|
* Permanently deletes all messages in the Trash mailbox.
|
|
@@ -46,15 +32,8 @@ const defaultDeps: EmptyTrashDeps = {
|
|
|
46
32
|
export const handleEmptyTrash = async (
|
|
47
33
|
event: EmptyTrashEvent,
|
|
48
34
|
log: Logger,
|
|
49
|
-
|
|
35
|
+
createConnectionScope = createConnectionScopeWithCredentials,
|
|
50
36
|
): Promise<void> => {
|
|
51
|
-
const {
|
|
52
|
-
getClient,
|
|
53
|
-
buildLifecycleDeps,
|
|
54
|
-
withOAuthLifecycle,
|
|
55
|
-
createConnectionScope: createConnectionScopeWithCredentials,
|
|
56
|
-
} = deps;
|
|
57
|
-
|
|
58
37
|
const {
|
|
59
38
|
account: accountService,
|
|
60
39
|
message: messageService,
|
|
@@ -219,7 +198,7 @@ export const handleEmptyTrash = async (
|
|
|
219
198
|
return;
|
|
220
199
|
}
|
|
221
200
|
|
|
222
|
-
const scope =
|
|
201
|
+
const scope = createConnectionScope(account, credentials);
|
|
223
202
|
|
|
224
203
|
await scope
|
|
225
204
|
.getConnection()
|