@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,266 @@
|
|
|
1
|
+
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
4
|
+
import { MailboxManagementService } from "@remit/mailbox-service";
|
|
5
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
6
|
+
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
7
|
+
import type {
|
|
8
|
+
MailboxCreateEvent,
|
|
9
|
+
MailboxDeleteEvent,
|
|
10
|
+
MailboxManagementEvent,
|
|
11
|
+
MailboxRenameEvent,
|
|
12
|
+
} from "../events.js";
|
|
13
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
14
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handle MAILBOX_CREATE event
|
|
18
|
+
*/
|
|
19
|
+
const handleCreate = async (
|
|
20
|
+
event: MailboxCreateEvent,
|
|
21
|
+
log: Logger,
|
|
22
|
+
): Promise<void> => {
|
|
23
|
+
const {
|
|
24
|
+
account: accountService,
|
|
25
|
+
mailbox: mailboxService,
|
|
26
|
+
secrets,
|
|
27
|
+
} = await getClient();
|
|
28
|
+
|
|
29
|
+
const { accountId, mailboxId, path, subscribe } = event;
|
|
30
|
+
|
|
31
|
+
log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
|
|
32
|
+
|
|
33
|
+
const account = await accountService.get(accountId);
|
|
34
|
+
if (!account) {
|
|
35
|
+
throw new Error(`Account ${accountId} not found`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isAccountDeleted(account, log)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await withOAuthLifecycle(
|
|
43
|
+
buildLifecycleDeps(secrets, accountService),
|
|
44
|
+
account,
|
|
45
|
+
log,
|
|
46
|
+
async (credentials) => {
|
|
47
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
48
|
+
const managementService = new MailboxManagementService(
|
|
49
|
+
mailboxService,
|
|
50
|
+
log,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
await managementService
|
|
54
|
+
.syncCreate(accountId, mailboxId, path, scope.getConnection, subscribe)
|
|
55
|
+
.then((result) => {
|
|
56
|
+
if (result.success) {
|
|
57
|
+
log.info({ accountId, mailboxId, path }, "Mailbox created on IMAP");
|
|
58
|
+
} else {
|
|
59
|
+
log.error(
|
|
60
|
+
{ accountId, mailboxId, path, error: result.error },
|
|
61
|
+
"Failed to create mailbox on IMAP",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.catch(async (error) => {
|
|
66
|
+
// Check if mailbox already exists (idempotent)
|
|
67
|
+
if (
|
|
68
|
+
error instanceof Error &&
|
|
69
|
+
error.message.includes("already exists")
|
|
70
|
+
) {
|
|
71
|
+
log.info(
|
|
72
|
+
{ accountId, mailboxId, path },
|
|
73
|
+
"Mailbox already exists, marking as synced",
|
|
74
|
+
);
|
|
75
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
76
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
80
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
81
|
+
});
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
.finally(() => scope.disconnect());
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Handle MAILBOX_RENAME event
|
|
92
|
+
*/
|
|
93
|
+
const handleRename = async (
|
|
94
|
+
event: MailboxRenameEvent,
|
|
95
|
+
log: Logger,
|
|
96
|
+
): Promise<void> => {
|
|
97
|
+
const {
|
|
98
|
+
account: accountService,
|
|
99
|
+
mailbox: mailboxService,
|
|
100
|
+
secrets,
|
|
101
|
+
} = await getClient();
|
|
102
|
+
|
|
103
|
+
const { accountId, mailboxId, oldPath, newPath } = event;
|
|
104
|
+
|
|
105
|
+
log.info(
|
|
106
|
+
{ event: event.type, accountId, mailboxId, oldPath, newPath },
|
|
107
|
+
"Handling event",
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const account = await accountService.get(accountId);
|
|
111
|
+
if (!account) {
|
|
112
|
+
throw new Error(`Account ${accountId} not found`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isAccountDeleted(account, log)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await withOAuthLifecycle(
|
|
120
|
+
buildLifecycleDeps(secrets, accountService),
|
|
121
|
+
account,
|
|
122
|
+
log,
|
|
123
|
+
async (credentials) => {
|
|
124
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
125
|
+
const managementService = new MailboxManagementService(
|
|
126
|
+
mailboxService,
|
|
127
|
+
log,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await managementService
|
|
131
|
+
.syncRename(accountId, mailboxId, oldPath, newPath, scope.getConnection)
|
|
132
|
+
.then((result) => {
|
|
133
|
+
if (result.success) {
|
|
134
|
+
log.info(
|
|
135
|
+
{ accountId, mailboxId, oldPath, newPath },
|
|
136
|
+
"Mailbox renamed on IMAP",
|
|
137
|
+
);
|
|
138
|
+
} else {
|
|
139
|
+
log.error(
|
|
140
|
+
{ accountId, mailboxId, oldPath, newPath, error: result.error },
|
|
141
|
+
"Failed to rename mailbox on IMAP",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
.catch(async (error) => {
|
|
146
|
+
// If source not found, delete local mailbox
|
|
147
|
+
if (error instanceof Error && error.message.includes("not found")) {
|
|
148
|
+
log.info(
|
|
149
|
+
{ accountId, mailboxId, oldPath },
|
|
150
|
+
"Source mailbox not found, deleting local",
|
|
151
|
+
);
|
|
152
|
+
await mailboxService.delete(accountId, mailboxId);
|
|
153
|
+
} else {
|
|
154
|
+
// Rollback local rename by restoring old path
|
|
155
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
156
|
+
fullPath: oldPath,
|
|
157
|
+
oldPath: undefined,
|
|
158
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
159
|
+
});
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
.finally(() => scope.disconnect());
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Handle MAILBOX_DELETE event
|
|
170
|
+
*/
|
|
171
|
+
const handleDelete = async (
|
|
172
|
+
event: MailboxDeleteEvent,
|
|
173
|
+
log: Logger,
|
|
174
|
+
): Promise<void> => {
|
|
175
|
+
const {
|
|
176
|
+
account: accountService,
|
|
177
|
+
mailbox: mailboxService,
|
|
178
|
+
secrets,
|
|
179
|
+
} = await getClient();
|
|
180
|
+
|
|
181
|
+
const { accountId, mailboxId, path } = event;
|
|
182
|
+
|
|
183
|
+
log.info({ event: event.type, accountId, mailboxId, path }, "Handling event");
|
|
184
|
+
|
|
185
|
+
const account = await accountService.get(accountId);
|
|
186
|
+
if (!account) {
|
|
187
|
+
throw new Error(`Account ${accountId} not found`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (isAccountDeleted(account, log)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await withOAuthLifecycle(
|
|
195
|
+
buildLifecycleDeps(secrets, accountService),
|
|
196
|
+
account,
|
|
197
|
+
log,
|
|
198
|
+
async (credentials) => {
|
|
199
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
200
|
+
const managementService = new MailboxManagementService(
|
|
201
|
+
mailboxService,
|
|
202
|
+
log,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await managementService
|
|
206
|
+
.syncDelete(accountId, mailboxId, path, scope.getConnection)
|
|
207
|
+
.then((result) => {
|
|
208
|
+
if (result.success) {
|
|
209
|
+
log.info({ accountId, mailboxId, path }, "Mailbox deleted on IMAP");
|
|
210
|
+
} else {
|
|
211
|
+
log.error(
|
|
212
|
+
{ accountId, mailboxId, path, error: result.error },
|
|
213
|
+
"Failed to delete mailbox on IMAP",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
.catch(async (error) => {
|
|
218
|
+
// If mailbox not found, it's already deleted (idempotent)
|
|
219
|
+
if (error instanceof Error && error.message.includes("not found")) {
|
|
220
|
+
log.info(
|
|
221
|
+
{ accountId, mailboxId, path },
|
|
222
|
+
"Mailbox not found on IMAP, deleting local",
|
|
223
|
+
);
|
|
224
|
+
await mailboxService.delete(accountId, mailboxId);
|
|
225
|
+
} else if (
|
|
226
|
+
error instanceof Error &&
|
|
227
|
+
error.message.includes("Cannot delete INBOX")
|
|
228
|
+
) {
|
|
229
|
+
// Restore the mailbox
|
|
230
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
231
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
232
|
+
});
|
|
233
|
+
log.error(
|
|
234
|
+
{ accountId, mailboxId, path },
|
|
235
|
+
"Cannot delete INBOX, restoring mailbox",
|
|
236
|
+
);
|
|
237
|
+
// Don't rethrow - this is an expected error
|
|
238
|
+
} else {
|
|
239
|
+
// Restore the mailbox on other errors
|
|
240
|
+
await mailboxService.update(accountId, mailboxId, {
|
|
241
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
242
|
+
});
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
.finally(() => scope.disconnect());
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Process mailbox management events
|
|
253
|
+
*/
|
|
254
|
+
export const processMailboxManagement = async (
|
|
255
|
+
event: MailboxManagementEvent,
|
|
256
|
+
log: Logger,
|
|
257
|
+
): Promise<void> => {
|
|
258
|
+
switch (event.type) {
|
|
259
|
+
case "MAILBOX_CREATE":
|
|
260
|
+
return handleCreate(event, log);
|
|
261
|
+
case "MAILBOX_RENAME":
|
|
262
|
+
return handleRename(event, log);
|
|
263
|
+
case "MAILBOX_DELETE":
|
|
264
|
+
return handleDelete(event, log);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
4
|
+
import {
|
|
5
|
+
type MailboxSyncOrderEntry,
|
|
6
|
+
mailboxSyncPriority,
|
|
7
|
+
orderMailboxesForSync,
|
|
8
|
+
} from "./mailbox-sync-order.js";
|
|
9
|
+
|
|
10
|
+
const mailbox = (
|
|
11
|
+
fullPath: string,
|
|
12
|
+
specialUse?: readonly string[],
|
|
13
|
+
): MailboxSyncOrderEntry => ({
|
|
14
|
+
mailboxId: `id-${fullPath}`,
|
|
15
|
+
fullPath,
|
|
16
|
+
specialUse,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("orderMailboxesForSync", () => {
|
|
20
|
+
it("orders INBOX first, Sent/Drafts next, Junk and Trash last", () => {
|
|
21
|
+
const mailboxes = [
|
|
22
|
+
mailbox("INBOX/Spam", [MailboxSpecialUse.Junk]),
|
|
23
|
+
mailbox("Trash", [MailboxSpecialUse.Trash]),
|
|
24
|
+
mailbox("Projects"),
|
|
25
|
+
mailbox("INBOX"),
|
|
26
|
+
mailbox("Sent", [MailboxSpecialUse.Sent]),
|
|
27
|
+
mailbox("Drafts", [MailboxSpecialUse.Drafts]),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const ordered = orderMailboxesForSync(mailboxes).map((m) => m.fullPath);
|
|
31
|
+
|
|
32
|
+
assert.deepStrictEqual(ordered, [
|
|
33
|
+
"INBOX",
|
|
34
|
+
"Sent",
|
|
35
|
+
"Drafts",
|
|
36
|
+
"Projects",
|
|
37
|
+
"INBOX/Spam",
|
|
38
|
+
"Trash",
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("places INBOX ahead of an alphabetically-earlier Junk folder", () => {
|
|
43
|
+
const ordered = orderMailboxesForSync([
|
|
44
|
+
mailbox("Bulk", [MailboxSpecialUse.Junk]),
|
|
45
|
+
mailbox("INBOX"),
|
|
46
|
+
]).map((m) => m.fullPath);
|
|
47
|
+
|
|
48
|
+
assert.deepStrictEqual(ordered, ["INBOX", "Bulk"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("is deterministic: ties break alphabetically by fullPath", () => {
|
|
52
|
+
const ordered = orderMailboxesForSync([
|
|
53
|
+
mailbox("Work"),
|
|
54
|
+
mailbox("Archive", [MailboxSpecialUse.Archive]),
|
|
55
|
+
mailbox("Newsletters"),
|
|
56
|
+
]).map((m) => m.fullPath);
|
|
57
|
+
|
|
58
|
+
assert.deepStrictEqual(ordered, ["Archive", "Newsletters", "Work"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not mutate the input array", () => {
|
|
62
|
+
const input = [
|
|
63
|
+
mailbox("Trash", [MailboxSpecialUse.Trash]),
|
|
64
|
+
mailbox("INBOX"),
|
|
65
|
+
];
|
|
66
|
+
const before = input.map((m) => m.fullPath);
|
|
67
|
+
orderMailboxesForSync(input);
|
|
68
|
+
assert.deepStrictEqual(
|
|
69
|
+
input.map((m) => m.fullPath),
|
|
70
|
+
before,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("treats INBOX case-insensitively", () => {
|
|
75
|
+
assert.strictEqual(mailboxSyncPriority(mailbox("inbox")), 0);
|
|
76
|
+
assert.strictEqual(mailboxSyncPriority(mailbox("Inbox")), 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("de-prioritises a folder carrying both a leading and a Junk flag", () => {
|
|
80
|
+
assert.ok(
|
|
81
|
+
mailboxSyncPriority(
|
|
82
|
+
mailbox("Weird", [MailboxSpecialUse.Sent, MailboxSpecialUse.Junk]),
|
|
83
|
+
) > mailboxSyncPriority(mailbox("Plain")),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("gives unflagged user folders the normal priority", () => {
|
|
88
|
+
assert.strictEqual(
|
|
89
|
+
mailboxSyncPriority(mailbox("Projects")),
|
|
90
|
+
mailboxSyncPriority(mailbox("Personal", [])),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
2
|
+
|
|
3
|
+
export type MailboxSyncOrderEntry = {
|
|
4
|
+
mailboxId: string;
|
|
5
|
+
fullPath: string;
|
|
6
|
+
specialUse?: readonly string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Sync priority by special-use, lowest first. Real mail leads (INBOX, then
|
|
10
|
+
// Sent and Drafts), normal folders sit in the middle at NORMAL_PRIORITY, and
|
|
11
|
+
// the low-value bulk folders (Junk/Spam, Trash) are pushed to the end so a
|
|
12
|
+
// fresh account fills its inbox before its spam (issue #567). Keyed by the
|
|
13
|
+
// bare runtime values from `@remit/domain-enums` (e.g. "Junk"), which match the
|
|
14
|
+
// values stored in the Mailbox `specialUse` DynamoDB set.
|
|
15
|
+
const INBOX_PRIORITY = 0;
|
|
16
|
+
const NORMAL_PRIORITY = 4;
|
|
17
|
+
|
|
18
|
+
const SPECIAL_USE_PRIORITY: Record<string, number> = {
|
|
19
|
+
[MailboxSpecialUse.Sent]: 1,
|
|
20
|
+
[MailboxSpecialUse.Drafts]: 2,
|
|
21
|
+
[MailboxSpecialUse.Flagged]: 3,
|
|
22
|
+
[MailboxSpecialUse.Junk]: 5,
|
|
23
|
+
[MailboxSpecialUse.Trash]: 6,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const isInbox = (fullPath: string): boolean =>
|
|
27
|
+
fullPath.toUpperCase() === "INBOX";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sync priority for a single mailbox. INBOX always leads; otherwise the
|
|
31
|
+
* mailbox's special-use flags resolve to a priority, defaulting to
|
|
32
|
+
* NORMAL_PRIORITY for plain user folders and unflagged mailboxes. When a
|
|
33
|
+
* mailbox carries both a leading flag (Sent/Drafts) and a trailing one
|
|
34
|
+
* (Junk/Trash) — rare, but possible with misconfigured servers — the trailing
|
|
35
|
+
* one wins: de-prioritising a bulk folder is the safer first-impression
|
|
36
|
+
* choice.
|
|
37
|
+
*/
|
|
38
|
+
export const mailboxSyncPriority = (entry: MailboxSyncOrderEntry): number => {
|
|
39
|
+
if (isInbox(entry.fullPath)) return INBOX_PRIORITY;
|
|
40
|
+
|
|
41
|
+
const priorities = (entry.specialUse ?? [])
|
|
42
|
+
.map((flag) => SPECIAL_USE_PRIORITY[flag])
|
|
43
|
+
.filter((priority): priority is number => priority !== undefined);
|
|
44
|
+
|
|
45
|
+
if (priorities.length === 0) return NORMAL_PRIORITY;
|
|
46
|
+
|
|
47
|
+
const trailing = priorities.filter((priority) => priority > NORMAL_PRIORITY);
|
|
48
|
+
if (trailing.length > 0) return Math.max(...trailing);
|
|
49
|
+
|
|
50
|
+
return Math.min(...priorities);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Order mailboxes for sync fan-out: INBOX first, then Sent/Drafts, then normal
|
|
55
|
+
* folders, with Junk/Spam and Trash last. Ties break alphabetically by
|
|
56
|
+
* fullPath so the order is deterministic.
|
|
57
|
+
*/
|
|
58
|
+
export const orderMailboxesForSync = <T extends MailboxSyncOrderEntry>(
|
|
59
|
+
mailboxes: readonly T[],
|
|
60
|
+
): T[] =>
|
|
61
|
+
[...mailboxes].sort((a, b) => {
|
|
62
|
+
const priorityDelta = mailboxSyncPriority(a) - mailboxSyncPriority(b);
|
|
63
|
+
if (priorityDelta !== 0) return priorityDelta;
|
|
64
|
+
return a.fullPath.localeCompare(b.fullPath);
|
|
65
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
3
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
4
|
+
import {
|
|
5
|
+
guardConnectionCursor,
|
|
6
|
+
isCursorRebuildNeeded,
|
|
7
|
+
MailboxCursorPausedError,
|
|
8
|
+
} from "@remit/mailbox-service";
|
|
9
|
+
import { isAccountDeleted } from "../account-check.js";
|
|
10
|
+
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
11
|
+
import type { MessageCopyEvent } from "../events.js";
|
|
12
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
13
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Handle MESSAGE_COPY events.
|
|
17
|
+
* Executes IMAP COPY command and updates local state with new UID.
|
|
18
|
+
*/
|
|
19
|
+
export const handleMessageCopy = async (
|
|
20
|
+
event: MessageCopyEvent,
|
|
21
|
+
log: Logger,
|
|
22
|
+
): Promise<void> => {
|
|
23
|
+
const {
|
|
24
|
+
account: accountService,
|
|
25
|
+
message: messageService,
|
|
26
|
+
threadMessage: threadMessageService,
|
|
27
|
+
mailbox: mailboxService,
|
|
28
|
+
secrets,
|
|
29
|
+
} = await getClient();
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
accountId,
|
|
33
|
+
sourceMessageId,
|
|
34
|
+
newMessageId,
|
|
35
|
+
sourceMailboxId,
|
|
36
|
+
sourceMailboxPath,
|
|
37
|
+
destinationMailboxPath,
|
|
38
|
+
destinationMailboxId,
|
|
39
|
+
uid,
|
|
40
|
+
} = event;
|
|
41
|
+
|
|
42
|
+
log.info(
|
|
43
|
+
{
|
|
44
|
+
event: event.type,
|
|
45
|
+
accountId,
|
|
46
|
+
sourceMessageId,
|
|
47
|
+
newMessageId,
|
|
48
|
+
from: sourceMailboxPath,
|
|
49
|
+
to: destinationMailboxPath,
|
|
50
|
+
},
|
|
51
|
+
"Handling event",
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const account = await accountService.get(accountId);
|
|
55
|
+
if (!account) {
|
|
56
|
+
throw new Error(`Account ${accountId} not found`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isAccountDeleted(account, log)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await withOAuthLifecycle(
|
|
64
|
+
buildLifecycleDeps(secrets, accountService),
|
|
65
|
+
account,
|
|
66
|
+
log,
|
|
67
|
+
async (credentials) => {
|
|
68
|
+
const mailbox = await mailboxService.get(accountId, sourceMailboxId);
|
|
69
|
+
|
|
70
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
71
|
+
// paused never even opens a connection. Optimization only — the
|
|
72
|
+
// guardConnectionCursor openBox wrap below is the structural guarantee.
|
|
73
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
74
|
+
log.info(
|
|
75
|
+
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
76
|
+
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
82
|
+
|
|
83
|
+
await scope
|
|
84
|
+
.getConnection()
|
|
85
|
+
.then(async (rawConnection) => {
|
|
86
|
+
// Guard at the openBox choke point (epic #1281 invariants 3 & 5):
|
|
87
|
+
// a fresh mismatch trips the mailbox and throws once the SELECT
|
|
88
|
+
// reveals it.
|
|
89
|
+
const connection = guardConnectionCursor(
|
|
90
|
+
rawConnection,
|
|
91
|
+
{ mailboxService },
|
|
92
|
+
accountId,
|
|
93
|
+
mailbox,
|
|
94
|
+
);
|
|
95
|
+
// Open source mailbox (read-only is fine for COPY)
|
|
96
|
+
await connection.openBox(sourceMailboxPath, true);
|
|
97
|
+
|
|
98
|
+
// Execute IMAP COPY
|
|
99
|
+
const result = await connection.copyMessages(
|
|
100
|
+
[uid],
|
|
101
|
+
destinationMailboxPath,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Get new UID from COPYUID response
|
|
105
|
+
const newUid = result.uidMap.get(uid);
|
|
106
|
+
|
|
107
|
+
if (newUid) {
|
|
108
|
+
// Update the new message with the actual UID
|
|
109
|
+
await messageService.updateUid(
|
|
110
|
+
newMessageId,
|
|
111
|
+
newUid,
|
|
112
|
+
destinationMailboxId,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Update message status to active
|
|
116
|
+
await messageService.update(newMessageId, {
|
|
117
|
+
status: MessageStatus.active,
|
|
118
|
+
syncStatus: MessageSyncStatus.synced,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Update ThreadMessage UID
|
|
122
|
+
const threadMessage = await threadMessageService.findByMessageId(
|
|
123
|
+
account.accountConfigId,
|
|
124
|
+
newMessageId,
|
|
125
|
+
);
|
|
126
|
+
if (threadMessage) {
|
|
127
|
+
await threadMessageService.update(
|
|
128
|
+
threadMessage.accountConfigId,
|
|
129
|
+
threadMessage.threadMessageId,
|
|
130
|
+
{ uid: newUid },
|
|
131
|
+
{
|
|
132
|
+
composites: {
|
|
133
|
+
sentDate: threadMessage.sentDate,
|
|
134
|
+
mailboxId: threadMessage.mailboxId,
|
|
135
|
+
isRead: threadMessage.isRead,
|
|
136
|
+
isDeleted: threadMessage.isDeleted,
|
|
137
|
+
hasStars: threadMessage.hasStars,
|
|
138
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
log.info(
|
|
145
|
+
{
|
|
146
|
+
sourceMessageId,
|
|
147
|
+
newMessageId,
|
|
148
|
+
oldUid: uid,
|
|
149
|
+
newUid,
|
|
150
|
+
destination: destinationMailboxPath,
|
|
151
|
+
},
|
|
152
|
+
"Message copied successfully",
|
|
153
|
+
);
|
|
154
|
+
} else {
|
|
155
|
+
// Source message may have been deleted on server
|
|
156
|
+
log.error(
|
|
157
|
+
{ sourceMessageId, uid },
|
|
158
|
+
"Source message not found in COPYUID response - may have been deleted",
|
|
159
|
+
);
|
|
160
|
+
await messageService.update(newMessageId, {
|
|
161
|
+
syncStatus: MessageSyncStatus.failed,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
.catch(async (error: unknown) => {
|
|
166
|
+
if (error instanceof MailboxCursorPausedError) {
|
|
167
|
+
log.info(
|
|
168
|
+
{
|
|
169
|
+
accountId,
|
|
170
|
+
sourceMessageId,
|
|
171
|
+
mailboxId: sourceMailboxId,
|
|
172
|
+
cursorState: error.state,
|
|
173
|
+
},
|
|
174
|
+
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const errorMessage =
|
|
180
|
+
error instanceof Error ? error.message : String(error);
|
|
181
|
+
|
|
182
|
+
// Handle TRYCREATE - destination doesn't exist
|
|
183
|
+
if (errorMessage.includes("TRYCREATE")) {
|
|
184
|
+
log.info(
|
|
185
|
+
{ destinationMailboxPath },
|
|
186
|
+
"Destination mailbox doesn't exist, creating",
|
|
187
|
+
);
|
|
188
|
+
const connection = await scope.getConnection();
|
|
189
|
+
await connection.createMailbox(destinationMailboxPath);
|
|
190
|
+
// Re-throw to let the event be retried
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Handle source message not found on IMAP - already deleted (fail the copy)
|
|
195
|
+
if (
|
|
196
|
+
errorMessage.includes("not found") ||
|
|
197
|
+
errorMessage.includes("NONEXISTENT")
|
|
198
|
+
) {
|
|
199
|
+
log.info(
|
|
200
|
+
{ sourceMessageId, uid },
|
|
201
|
+
"Source message not found on IMAP, marking copy as failed",
|
|
202
|
+
);
|
|
203
|
+
await messageService.update(newMessageId, {
|
|
204
|
+
status: MessageStatus.deleted,
|
|
205
|
+
syncStatus: MessageSyncStatus.failed,
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Mark as failed for other errors
|
|
211
|
+
await messageService.update(newMessageId, {
|
|
212
|
+
syncStatus: MessageSyncStatus.failed,
|
|
213
|
+
});
|
|
214
|
+
throw error;
|
|
215
|
+
})
|
|
216
|
+
.finally(() => scope.disconnect());
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
};
|