@remit/imap-worker 0.0.1
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 +100 -0
- package/build.mjs +17 -0
- package/package.json +50 -0
- package/src/account-check.test.ts +122 -0
- package/src/account-check.ts +88 -0
- package/src/body-sync-gate.test.ts +185 -0
- package/src/body-sync-gate.ts +86 -0
- package/src/cli.ts +211 -0
- package/src/connection-scope.test.ts +266 -0
- package/src/connection-scope.ts +335 -0
- package/src/e2e-processor-shim.ts +248 -0
- package/src/emit.test.ts +44 -0
- package/src/emit.ts +142 -0
- package/src/events.ts +221 -0
- package/src/handlers/append-sent-message.ts +163 -0
- package/src/handlers/delete-account-objects.test.ts +81 -0
- package/src/handlers/delete-account-objects.ts +116 -0
- package/src/handlers/empty-trash.ts +136 -0
- package/src/handlers/flag-push.test.ts +25 -0
- package/src/handlers/flag-push.ts +224 -0
- package/src/handlers/mailbox-management.ts +266 -0
- package/src/handlers/mailbox-sync-order.test.ts +93 -0
- package/src/handlers/mailbox-sync-order.ts +65 -0
- package/src/handlers/message-copy.ts +219 -0
- package/src/handlers/message-delete.test.ts +176 -0
- package/src/handlers/message-delete.ts +283 -0
- package/src/handlers/message-move.test.ts +168 -0
- package/src/handlers/message-move.ts +298 -0
- package/src/handlers/placement-move-push.test.ts +234 -0
- package/src/handlers/placement-move-push.ts +434 -0
- package/src/handlers/sync-mailboxes.ts +241 -0
- package/src/handlers/sync-message-body.test.ts +375 -0
- package/src/handlers/sync-message-body.ts +337 -0
- package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
- package/src/handlers/sync-messages.test.ts +204 -0
- package/src/handlers/sync-messages.ts +412 -0
- package/src/handlers/sync-reserved-host.test.ts +97 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +70 -0
- package/src/poller.ts +49 -0
- package/src/processor.test.ts +58 -0
- package/src/processor.ts +66 -0
- package/src/scheduler/config.test.ts +40 -0
- package/src/scheduler/config.ts +52 -0
- package/src/scheduler/decide-due.test.ts +44 -0
- package/src/scheduler/decide-due.ts +26 -0
- package/src/scheduler/handler.ts +52 -0
- package/src/scheduler/local-runner.ts +76 -0
- package/src/scheduler/run-tick.test.ts +248 -0
- package/src/scheduler/run-tick.ts +141 -0
- package/src/with-oauth-lifecycle-deps.ts +62 -0
- package/src/with-oauth-lifecycle.test.ts +227 -0
- package/src/with-oauth-lifecycle.ts +125 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, it, mock } from "node:test";
|
|
3
|
+
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
buildThreadMessageTrashUpdate,
|
|
6
|
+
deleteAllThreadMessagesForMessage,
|
|
7
|
+
} from "./message-delete.js";
|
|
8
|
+
|
|
9
|
+
const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
|
|
10
|
+
const trashMailboxId = "trash-mailbox-id-aaaaaaaaa";
|
|
11
|
+
|
|
12
|
+
const baseThreadMessage = {
|
|
13
|
+
sentDate: 1700000000000,
|
|
14
|
+
mailboxId: sourceMailboxId,
|
|
15
|
+
isRead: true,
|
|
16
|
+
isDeleted: false,
|
|
17
|
+
hasStars: true,
|
|
18
|
+
hasAttachment: false,
|
|
19
|
+
} satisfies Pick<
|
|
20
|
+
ThreadMessageItem,
|
|
21
|
+
| "sentDate"
|
|
22
|
+
| "mailboxId"
|
|
23
|
+
| "isRead"
|
|
24
|
+
| "isDeleted"
|
|
25
|
+
| "hasStars"
|
|
26
|
+
| "hasAttachment"
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
describe("buildThreadMessageTrashUpdate", () => {
|
|
30
|
+
// Regression for the same composites-direction landmine PR #186 fixed in
|
|
31
|
+
// `flag-queue.ts`. The CURRENT row state must go in `composites`; the NEW
|
|
32
|
+
// values must go in `set`. Flipping any of these silently drops the
|
|
33
|
+
// move-to-trash update on the ThreadMessage row — IMAP shows the message in
|
|
34
|
+
// Trash but the local thread-list still shows it in the source mailbox.
|
|
35
|
+
|
|
36
|
+
it("set carries the NEW uid, destination mailboxId, and isDeleted=true", () => {
|
|
37
|
+
const args = buildThreadMessageTrashUpdate(
|
|
38
|
+
baseThreadMessage,
|
|
39
|
+
42,
|
|
40
|
+
trashMailboxId,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
assert.strictEqual(args.set.uid, 42);
|
|
44
|
+
assert.strictEqual(
|
|
45
|
+
args.set.mailboxId,
|
|
46
|
+
trashMailboxId,
|
|
47
|
+
"set.mailboxId must be the NEW trash mailbox",
|
|
48
|
+
);
|
|
49
|
+
assert.strictEqual(
|
|
50
|
+
args.set.isDeleted,
|
|
51
|
+
true,
|
|
52
|
+
"set.isDeleted must be true (move-to-trash marks the row deleted)",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("composites.mailboxId is the CURRENT (source) mailboxId, not Trash", () => {
|
|
57
|
+
const args = buildThreadMessageTrashUpdate(
|
|
58
|
+
baseThreadMessage,
|
|
59
|
+
42,
|
|
60
|
+
trashMailboxId,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(
|
|
64
|
+
args.composites.mailboxId,
|
|
65
|
+
sourceMailboxId,
|
|
66
|
+
"composites.mailboxId must be the CURRENT source mailbox; passing Trash breaks the conditional check",
|
|
67
|
+
);
|
|
68
|
+
assert.notStrictEqual(
|
|
69
|
+
args.composites.mailboxId,
|
|
70
|
+
trashMailboxId,
|
|
71
|
+
"composites.mailboxId must NOT match the new trash mailbox id",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("composites.isDeleted is the CURRENT value, not the new true", () => {
|
|
76
|
+
const args = buildThreadMessageTrashUpdate(
|
|
77
|
+
baseThreadMessage,
|
|
78
|
+
42,
|
|
79
|
+
trashMailboxId,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
assert.strictEqual(
|
|
83
|
+
args.composites.isDeleted,
|
|
84
|
+
false,
|
|
85
|
+
"composites.isDeleted must be the CURRENT value (false), not the new value (true)",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("composites mirrors every CURRENT indexed attribute on the threadMessage", () => {
|
|
90
|
+
const tm = {
|
|
91
|
+
sentDate: 1700000000123,
|
|
92
|
+
mailboxId: sourceMailboxId,
|
|
93
|
+
isRead: false,
|
|
94
|
+
isDeleted: false,
|
|
95
|
+
hasStars: true,
|
|
96
|
+
hasAttachment: true,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const args = buildThreadMessageTrashUpdate(tm, 99, trashMailboxId);
|
|
100
|
+
|
|
101
|
+
assert.deepStrictEqual(args.composites, {
|
|
102
|
+
sentDate: tm.sentDate,
|
|
103
|
+
mailboxId: tm.mailboxId,
|
|
104
|
+
isRead: tm.isRead,
|
|
105
|
+
isDeleted: tm.isDeleted,
|
|
106
|
+
hasStars: tm.hasStars,
|
|
107
|
+
hasAttachment: tm.hasAttachment,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("deleteAllThreadMessagesForMessage (#212)", () => {
|
|
113
|
+
// Regression for the multi-mailbox cleanup gap in #212. A single Message
|
|
114
|
+
// can have ThreadMessage rows in multiple mailboxes (e.g. INBOX + a label
|
|
115
|
+
// folder copy). The pre-fix code used `findByMessageId` (single row) and
|
|
116
|
+
// left orphan rows behind that then leaked into other mailbox listings.
|
|
117
|
+
|
|
118
|
+
const baseRow = (
|
|
119
|
+
threadMessageId: string,
|
|
120
|
+
mailboxId: string,
|
|
121
|
+
): Pick<
|
|
122
|
+
ThreadMessageItem,
|
|
123
|
+
"accountConfigId" | "threadMessageId" | "mailboxId"
|
|
124
|
+
> => ({
|
|
125
|
+
accountConfigId: "alice-config-aaaaaaaaaa",
|
|
126
|
+
threadMessageId,
|
|
127
|
+
mailboxId,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("deletes every ThreadMessage row returned by findAllByMessageId", async () => {
|
|
131
|
+
const rows = [
|
|
132
|
+
baseRow("alice-tm-1-aaaaaaaaaa", "alice-inbox-aaaaaaaaa"),
|
|
133
|
+
baseRow("alice-tm-2-aaaaaaaaaa", "alice-label-aaaaaaaaa"),
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const findAllByMessageId = mock.fn(async () => rows);
|
|
137
|
+
const deleteRow = mock.fn(async () => undefined);
|
|
138
|
+
|
|
139
|
+
const count = await deleteAllThreadMessagesForMessage(
|
|
140
|
+
{
|
|
141
|
+
findAllByMessageId,
|
|
142
|
+
delete: deleteRow,
|
|
143
|
+
} as unknown as Parameters<typeof deleteAllThreadMessagesForMessage>[0],
|
|
144
|
+
"alice-config-aaaaaaaaaa",
|
|
145
|
+
"alice-msg-multi-aaaaaaaa",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
assert.equal(count, 2);
|
|
149
|
+
assert.equal(deleteRow.mock.calls.length, 2);
|
|
150
|
+
assert.deepEqual(deleteRow.mock.calls[0].arguments, [
|
|
151
|
+
"alice-config-aaaaaaaaaa",
|
|
152
|
+
"alice-tm-1-aaaaaaaaaa",
|
|
153
|
+
]);
|
|
154
|
+
assert.deepEqual(deleteRow.mock.calls[1].arguments, [
|
|
155
|
+
"alice-config-aaaaaaaaaa",
|
|
156
|
+
"alice-tm-2-aaaaaaaaaa",
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns zero when no ThreadMessage rows exist", async () => {
|
|
161
|
+
const findAllByMessageId = mock.fn(async () => []);
|
|
162
|
+
const deleteRow = mock.fn(async () => undefined);
|
|
163
|
+
|
|
164
|
+
const count = await deleteAllThreadMessagesForMessage(
|
|
165
|
+
{
|
|
166
|
+
findAllByMessageId,
|
|
167
|
+
delete: deleteRow,
|
|
168
|
+
} as unknown as Parameters<typeof deleteAllThreadMessagesForMessage>[0],
|
|
169
|
+
"alice-config-aaaaaaaaaa",
|
|
170
|
+
"alice-msg-missing-aaaaaa",
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
assert.equal(count, 0);
|
|
174
|
+
assert.equal(deleteRow.mock.calls.length, 0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import type {
|
|
3
|
+
IThreadMessageRepository,
|
|
4
|
+
ThreadMessageItem,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import { MessageSyncStatus } from "@remit/domain-enums";
|
|
7
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
8
|
+
import {
|
|
9
|
+
guardConnectionCursor,
|
|
10
|
+
isCursorRebuildNeeded,
|
|
11
|
+
MailboxCursorPausedError,
|
|
12
|
+
} from "@remit/mailbox-service";
|
|
13
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
14
|
+
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
15
|
+
import type { MessageDeleteEvent } from "../events.js";
|
|
16
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
17
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Delete every ThreadMessage row that points at this messageId.
|
|
21
|
+
*
|
|
22
|
+
* A single Message can have multiple ThreadMessage rows — one per mailbox
|
|
23
|
+
* the message exists in (e.g. INBOX + a label/folder copy). Permanent-delete
|
|
24
|
+
* cleanup must remove ALL of them; using `findByMessageId` (single row) leaves
|
|
25
|
+
* orphan rows in other mailboxes that then leak into their listings. See
|
|
26
|
+
* issue #212.
|
|
27
|
+
*/
|
|
28
|
+
export const deleteAllThreadMessagesForMessage = async (
|
|
29
|
+
threadMessageService: Pick<
|
|
30
|
+
IThreadMessageRepository,
|
|
31
|
+
"findAllByMessageId" | "delete"
|
|
32
|
+
>,
|
|
33
|
+
accountConfigId: string,
|
|
34
|
+
messageId: string,
|
|
35
|
+
): Promise<number> => {
|
|
36
|
+
const rows = await threadMessageService.findAllByMessageId(
|
|
37
|
+
accountConfigId,
|
|
38
|
+
messageId,
|
|
39
|
+
);
|
|
40
|
+
for (const row of rows) {
|
|
41
|
+
await threadMessageService.delete(row.accountConfigId, row.threadMessageId);
|
|
42
|
+
}
|
|
43
|
+
return rows.length;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the `set` and `composites` payload for the ThreadMessage update on a
|
|
48
|
+
* MESSAGE_DELETE move-to-trash.
|
|
49
|
+
*
|
|
50
|
+
* The CURRENT row state goes in `composites`; the NEW values go in `set`.
|
|
51
|
+
* ElectroDB uses `composites` to run the conditional check on the existing row
|
|
52
|
+
* AND to compute the previous sort-key values needed to recompute the new ones.
|
|
53
|
+
* Passing the NEW values in `composites` makes the conditional check fail with
|
|
54
|
+
* ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
|
|
55
|
+
* the caller silently drops the update. Same root cause as PR #186 fixed for
|
|
56
|
+
* `flag-queue.ts`.
|
|
57
|
+
*/
|
|
58
|
+
export const buildThreadMessageTrashUpdate = (
|
|
59
|
+
threadMessage: Pick<
|
|
60
|
+
ThreadMessageItem,
|
|
61
|
+
| "sentDate"
|
|
62
|
+
| "mailboxId"
|
|
63
|
+
| "isRead"
|
|
64
|
+
| "isDeleted"
|
|
65
|
+
| "hasStars"
|
|
66
|
+
| "hasAttachment"
|
|
67
|
+
>,
|
|
68
|
+
newUid: number,
|
|
69
|
+
destinationMailboxId: string,
|
|
70
|
+
) => ({
|
|
71
|
+
set: {
|
|
72
|
+
uid: newUid,
|
|
73
|
+
mailboxId: destinationMailboxId,
|
|
74
|
+
isDeleted: true,
|
|
75
|
+
},
|
|
76
|
+
composites: {
|
|
77
|
+
sentDate: threadMessage.sentDate,
|
|
78
|
+
mailboxId: threadMessage.mailboxId,
|
|
79
|
+
isRead: threadMessage.isRead,
|
|
80
|
+
isDeleted: threadMessage.isDeleted,
|
|
81
|
+
hasStars: threadMessage.hasStars,
|
|
82
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Handle MESSAGE_DELETE events.
|
|
88
|
+
* Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
|
|
89
|
+
*/
|
|
90
|
+
export const handleMessageDelete = async (
|
|
91
|
+
event: MessageDeleteEvent,
|
|
92
|
+
log: Logger,
|
|
93
|
+
): Promise<void> => {
|
|
94
|
+
const {
|
|
95
|
+
account: accountService,
|
|
96
|
+
message: messageService,
|
|
97
|
+
threadMessage: threadMessageService,
|
|
98
|
+
mailbox: mailboxService,
|
|
99
|
+
secrets,
|
|
100
|
+
} = await getClient();
|
|
101
|
+
|
|
102
|
+
const {
|
|
103
|
+
accountId,
|
|
104
|
+
messageId,
|
|
105
|
+
mailboxId,
|
|
106
|
+
mailboxPath,
|
|
107
|
+
uid,
|
|
108
|
+
operation,
|
|
109
|
+
destinationMailboxId,
|
|
110
|
+
destinationMailboxPath,
|
|
111
|
+
} = event;
|
|
112
|
+
|
|
113
|
+
log.info(
|
|
114
|
+
{ event: event.type, accountId, messageId, mailboxPath, operation },
|
|
115
|
+
"Handling event",
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const account = await accountService.get(accountId);
|
|
119
|
+
if (!account) {
|
|
120
|
+
throw new Error(`Account ${accountId} not found`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isAccountDeleted(account, log)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await withOAuthLifecycle(
|
|
128
|
+
buildLifecycleDeps(secrets, accountService),
|
|
129
|
+
account,
|
|
130
|
+
log,
|
|
131
|
+
async (credentials) => {
|
|
132
|
+
const mailbox = await mailboxService.get(accountId, mailboxId);
|
|
133
|
+
|
|
134
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
135
|
+
// paused never even opens a connection. Optimization only — the
|
|
136
|
+
// guardConnectionCursor openBox wrap below is the structural guarantee.
|
|
137
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
138
|
+
log.info(
|
|
139
|
+
{ accountId, messageId, mailboxId },
|
|
140
|
+
"Mailbox cursor not normal; pausing outbound delete this round",
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
146
|
+
|
|
147
|
+
await scope
|
|
148
|
+
.getConnection()
|
|
149
|
+
.then(async (rawConnection) => {
|
|
150
|
+
// Guard at the openBox choke point (epic #1281 invariants 3 & 5):
|
|
151
|
+
// a fresh mismatch trips the mailbox and throws once the SELECT
|
|
152
|
+
// reveals it. The delete stays applied locally either way.
|
|
153
|
+
const connection = guardConnectionCursor(
|
|
154
|
+
rawConnection,
|
|
155
|
+
{ mailboxService },
|
|
156
|
+
accountId,
|
|
157
|
+
mailbox,
|
|
158
|
+
);
|
|
159
|
+
await connection.openBox(mailboxPath, false);
|
|
160
|
+
|
|
161
|
+
if (operation === "move_to_trash" && destinationMailboxPath) {
|
|
162
|
+
// Move to Trash
|
|
163
|
+
const result = await connection.moveMessages(
|
|
164
|
+
[uid],
|
|
165
|
+
destinationMailboxPath,
|
|
166
|
+
);
|
|
167
|
+
const newUid = result.uidMap.get(uid);
|
|
168
|
+
|
|
169
|
+
if (newUid && destinationMailboxId) {
|
|
170
|
+
// Update message with new UID in Trash
|
|
171
|
+
await messageService.updateUid(
|
|
172
|
+
messageId,
|
|
173
|
+
newUid,
|
|
174
|
+
destinationMailboxId,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Update ThreadMessage with new UID and isDeleted = true
|
|
178
|
+
const threadMessage = await threadMessageService.findByMessageId(
|
|
179
|
+
account.accountConfigId,
|
|
180
|
+
messageId,
|
|
181
|
+
);
|
|
182
|
+
if (threadMessage) {
|
|
183
|
+
const args = buildThreadMessageTrashUpdate(
|
|
184
|
+
threadMessage,
|
|
185
|
+
newUid,
|
|
186
|
+
destinationMailboxId,
|
|
187
|
+
);
|
|
188
|
+
await threadMessageService.update(
|
|
189
|
+
threadMessage.accountConfigId,
|
|
190
|
+
threadMessage.threadMessageId,
|
|
191
|
+
args.set,
|
|
192
|
+
{ composites: args.composites },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
log.info({ messageId, newUid }, "Message moved to trash");
|
|
197
|
+
} else {
|
|
198
|
+
log.error(
|
|
199
|
+
{ messageId, uid },
|
|
200
|
+
"Failed to get new UID after move to trash",
|
|
201
|
+
);
|
|
202
|
+
await messageService.update(messageId, {
|
|
203
|
+
syncStatus: MessageSyncStatus.failed,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
// Permanent delete
|
|
208
|
+
await connection.deleteMessages([uid]);
|
|
209
|
+
|
|
210
|
+
// Delete ThreadMessage rows BEFORE the Message row to collapse the
|
|
211
|
+
// visibility window where the inbox lists a row whose backing
|
|
212
|
+
// Message has already been deleted (see issue #212). Multi-mailbox
|
|
213
|
+
// copies are handled by the helper.
|
|
214
|
+
const threadMessagesDeleted =
|
|
215
|
+
await deleteAllThreadMessagesForMessage(
|
|
216
|
+
threadMessageService,
|
|
217
|
+
account.accountConfigId,
|
|
218
|
+
messageId,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Delete local Message entity
|
|
222
|
+
await messageService.delete(messageId);
|
|
223
|
+
|
|
224
|
+
log.info(
|
|
225
|
+
{ messageId, threadMessagesDeleted },
|
|
226
|
+
"Message permanently deleted",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
.catch(async (error: unknown) => {
|
|
231
|
+
if (error instanceof MailboxCursorPausedError) {
|
|
232
|
+
log.info(
|
|
233
|
+
{ accountId, messageId, mailboxId, cursorState: error.state },
|
|
234
|
+
"Mailbox cursor not normal; pausing outbound delete this round",
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const errorMessage =
|
|
240
|
+
error instanceof Error ? error.message : String(error);
|
|
241
|
+
|
|
242
|
+
// Message not found on IMAP - already deleted (idempotent)
|
|
243
|
+
if (
|
|
244
|
+
errorMessage.includes("not found") ||
|
|
245
|
+
errorMessage.includes("NONEXISTENT")
|
|
246
|
+
) {
|
|
247
|
+
log.info(
|
|
248
|
+
{ messageId, uid },
|
|
249
|
+
"Message not found on IMAP, cleaning up local",
|
|
250
|
+
);
|
|
251
|
+
// Clean up local entities. Multi-mailbox copies are handled by the
|
|
252
|
+
// helper so we don't leave orphan rows (see issue #212).
|
|
253
|
+
await messageService.delete(messageId);
|
|
254
|
+
await deleteAllThreadMessagesForMessage(
|
|
255
|
+
threadMessageService,
|
|
256
|
+
account.accountConfigId,
|
|
257
|
+
messageId,
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// TRYCREATE - Trash doesn't exist
|
|
263
|
+
if (errorMessage.includes("TRYCREATE") && destinationMailboxPath) {
|
|
264
|
+
log.info(
|
|
265
|
+
{ destinationMailboxPath },
|
|
266
|
+
"Trash mailbox doesn't exist, creating",
|
|
267
|
+
);
|
|
268
|
+
const connection = await scope.getConnection();
|
|
269
|
+
await connection.createMailbox(destinationMailboxPath);
|
|
270
|
+
// Re-throw to let the event be retried
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Mark as failed for other errors
|
|
275
|
+
await messageService.update(messageId, {
|
|
276
|
+
syncStatus: MessageSyncStatus.failed,
|
|
277
|
+
});
|
|
278
|
+
throw error;
|
|
279
|
+
})
|
|
280
|
+
.finally(() => scope.disconnect());
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, it, mock } from "node:test";
|
|
3
|
+
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
buildThreadMessageMoveUpdate,
|
|
6
|
+
emitMoveResync,
|
|
7
|
+
moveThenResync,
|
|
8
|
+
} from "./message-move.js";
|
|
9
|
+
|
|
10
|
+
const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
|
|
11
|
+
const destinationMailboxId = "destination-mailbox-aaaaa";
|
|
12
|
+
|
|
13
|
+
const baseThreadMessage = {
|
|
14
|
+
sentDate: 1700000000000,
|
|
15
|
+
mailboxId: sourceMailboxId,
|
|
16
|
+
isRead: true,
|
|
17
|
+
isDeleted: false,
|
|
18
|
+
hasStars: true,
|
|
19
|
+
hasAttachment: false,
|
|
20
|
+
} satisfies Pick<
|
|
21
|
+
ThreadMessageItem,
|
|
22
|
+
| "sentDate"
|
|
23
|
+
| "mailboxId"
|
|
24
|
+
| "isRead"
|
|
25
|
+
| "isDeleted"
|
|
26
|
+
| "hasStars"
|
|
27
|
+
| "hasAttachment"
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
describe("buildThreadMessageMoveUpdate", () => {
|
|
31
|
+
// Regression for the same composites-direction landmine PR #186 fixed in
|
|
32
|
+
// `flag-queue.ts`. The CURRENT row state must go in `composites`; the NEW
|
|
33
|
+
// values must go in `set`. Flipping any of these silently drops the
|
|
34
|
+
// ThreadMessage local update on every IMAP MOVE — the row stays in the
|
|
35
|
+
// source mailbox while the IMAP server thinks it moved.
|
|
36
|
+
|
|
37
|
+
it("set carries the NEW uid, mailboxId, and isDeleted=false", () => {
|
|
38
|
+
const args = buildThreadMessageMoveUpdate(
|
|
39
|
+
baseThreadMessage,
|
|
40
|
+
42,
|
|
41
|
+
destinationMailboxId,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
assert.strictEqual(args.set.uid, 42, "set.uid must be the NEW uid");
|
|
45
|
+
assert.strictEqual(
|
|
46
|
+
args.set.mailboxId,
|
|
47
|
+
destinationMailboxId,
|
|
48
|
+
"set.mailboxId must be the NEW destination mailbox",
|
|
49
|
+
);
|
|
50
|
+
assert.strictEqual(
|
|
51
|
+
args.set.isDeleted,
|
|
52
|
+
false,
|
|
53
|
+
"set.isDeleted must be false (move is not a delete)",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("composites.mailboxId is the CURRENT (source) mailboxId, not the destination", () => {
|
|
58
|
+
const args = buildThreadMessageMoveUpdate(
|
|
59
|
+
baseThreadMessage,
|
|
60
|
+
42,
|
|
61
|
+
destinationMailboxId,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
assert.strictEqual(
|
|
65
|
+
args.composites.mailboxId,
|
|
66
|
+
sourceMailboxId,
|
|
67
|
+
"composites.mailboxId must be the CURRENT source mailbox; passing the destination breaks the conditional check",
|
|
68
|
+
);
|
|
69
|
+
assert.notStrictEqual(
|
|
70
|
+
args.composites.mailboxId,
|
|
71
|
+
destinationMailboxId,
|
|
72
|
+
"composites.mailboxId must NOT match the new destinationMailboxId",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("composites.isDeleted is the CURRENT value, not the new false", () => {
|
|
77
|
+
const args = buildThreadMessageMoveUpdate(
|
|
78
|
+
{ ...baseThreadMessage, isDeleted: true },
|
|
79
|
+
42,
|
|
80
|
+
destinationMailboxId,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
assert.strictEqual(
|
|
84
|
+
args.composites.isDeleted,
|
|
85
|
+
true,
|
|
86
|
+
"composites.isDeleted must be the CURRENT value (true here), not the new value",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("composites mirrors every CURRENT indexed attribute on the threadMessage", () => {
|
|
91
|
+
const tm = {
|
|
92
|
+
sentDate: 1700000000123,
|
|
93
|
+
mailboxId: sourceMailboxId,
|
|
94
|
+
isRead: false,
|
|
95
|
+
isDeleted: true,
|
|
96
|
+
hasStars: true,
|
|
97
|
+
hasAttachment: true,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const args = buildThreadMessageMoveUpdate(tm, 99, destinationMailboxId);
|
|
101
|
+
|
|
102
|
+
assert.deepStrictEqual(args.composites, {
|
|
103
|
+
sentDate: tm.sentDate,
|
|
104
|
+
mailboxId: tm.mailboxId,
|
|
105
|
+
isRead: tm.isRead,
|
|
106
|
+
isDeleted: tm.isDeleted,
|
|
107
|
+
hasStars: tm.hasStars,
|
|
108
|
+
hasAttachment: tm.hasAttachment,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const accountId = "alice-account-aaaaaaaaaa";
|
|
114
|
+
|
|
115
|
+
describe("emitMoveResync (#1031)", () => {
|
|
116
|
+
// A move shifts a message between two folders; both folders' counts must
|
|
117
|
+
// refresh from IMAP via the existing per-folder SYNC_MESSAGES sync. Counts
|
|
118
|
+
// are never mutated locally — only re-read downstream.
|
|
119
|
+
|
|
120
|
+
it("emits SYNC_MESSAGES for both the source and destination folders", async () => {
|
|
121
|
+
const emit = mock.fn(async () => undefined);
|
|
122
|
+
|
|
123
|
+
await emitMoveResync(emit, {
|
|
124
|
+
accountId,
|
|
125
|
+
sourceMailboxId,
|
|
126
|
+
destinationMailboxId,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.equal(emit.mock.calls.length, 2);
|
|
130
|
+
assert.deepEqual(emit.mock.calls[0].arguments, [
|
|
131
|
+
{ type: "SYNC_MESSAGES", accountId, mailboxId: sourceMailboxId },
|
|
132
|
+
]);
|
|
133
|
+
assert.deepEqual(emit.mock.calls[1].arguments, [
|
|
134
|
+
{ type: "SYNC_MESSAGES", accountId, mailboxId: destinationMailboxId },
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("moveThenResync (#1031)", () => {
|
|
140
|
+
it("runs the resync after the move resolves", async () => {
|
|
141
|
+
const order: string[] = [];
|
|
142
|
+
const performMove = mock.fn(async () => {
|
|
143
|
+
order.push("move");
|
|
144
|
+
});
|
|
145
|
+
const resync = mock.fn(async () => {
|
|
146
|
+
order.push("resync");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await moveThenResync(performMove, resync);
|
|
150
|
+
|
|
151
|
+
assert.deepEqual(order, ["move", "resync"]);
|
|
152
|
+
assert.equal(resync.mock.calls.length, 1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not resync when the move fails, and propagates the error", async () => {
|
|
156
|
+
const performMove = mock.fn(async () => {
|
|
157
|
+
throw new Error("IMAP move failed");
|
|
158
|
+
});
|
|
159
|
+
const resync = mock.fn(async () => undefined);
|
|
160
|
+
|
|
161
|
+
await assert.rejects(
|
|
162
|
+
() => moveThenResync(performMove, resync),
|
|
163
|
+
/IMAP move failed/,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
assert.equal(resync.mock.calls.length, 0);
|
|
167
|
+
});
|
|
168
|
+
});
|