@remit/imap-worker 0.0.45 → 0.0.47
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 +1 -1
- package/src/handlers/append-sent-message.test.ts +6 -74
- package/src/handlers/append-sent-message.ts +3 -30
- package/src/handlers/message-delete.test.ts +61 -0
- package/src/handlers/message-delete.ts +99 -19
- package/src/handlers/sync-messages.ts +1 -0
- package/src/sent-mailbox.test.ts +0 -112
- package/src/sent-mailbox.ts +0 -20
package/package.json
CHANGED
|
@@ -31,12 +31,7 @@ interface Harness {
|
|
|
31
31
|
deletedAt?: number;
|
|
32
32
|
};
|
|
33
33
|
outbox: Record<string, unknown>;
|
|
34
|
-
|
|
35
|
-
mailboxes: {
|
|
36
|
-
mailboxId: string;
|
|
37
|
-
fullPath: string;
|
|
38
|
-
hierarchyDelimiter: string;
|
|
39
|
-
}[];
|
|
34
|
+
sentMailbox: { mailboxId: string; fullPath: string } | null;
|
|
40
35
|
append: (
|
|
41
36
|
path: string,
|
|
42
37
|
raw: Buffer,
|
|
@@ -72,15 +67,7 @@ const fresh = (): Harness => ({
|
|
|
72
67
|
inReplyTo: "parent@example.com",
|
|
73
68
|
sentAt: 1700000000000,
|
|
74
69
|
},
|
|
75
|
-
|
|
76
|
-
mailboxes: [
|
|
77
|
-
{ mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "/" },
|
|
78
|
-
{
|
|
79
|
-
mailboxId: "sent-items-mbx",
|
|
80
|
-
fullPath: "Sent Items",
|
|
81
|
-
hierarchyDelimiter: "/",
|
|
82
|
-
},
|
|
83
|
-
],
|
|
70
|
+
sentMailbox: { mailboxId: "sent-mbx", fullPath: "INBOX/Sent" },
|
|
84
71
|
append: async (path, raw, flags) => {
|
|
85
72
|
h.calls.push({ method: "connection.append", args: [path, raw, flags] });
|
|
86
73
|
return { uid: 55, uidValidity: 7 };
|
|
@@ -107,10 +94,7 @@ const deps = (): AppendSentMessageDeps =>
|
|
|
107
94
|
discardAll: record("outboxAttachment.discardAll"),
|
|
108
95
|
},
|
|
109
96
|
mailboxSpecialUse: {
|
|
110
|
-
|
|
111
|
-
},
|
|
112
|
-
mailbox: {
|
|
113
|
-
listAllByAccount: async () => h.mailboxes,
|
|
97
|
+
findSentMailbox: async () => h.sentMailbox,
|
|
114
98
|
},
|
|
115
99
|
secrets: {},
|
|
116
100
|
}),
|
|
@@ -150,7 +134,7 @@ describe("handleAppendSentMessage", () => {
|
|
|
150
134
|
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
151
135
|
|
|
152
136
|
const append = called("connection.append")[0];
|
|
153
|
-
assert.equal(append?.args[0], "Sent");
|
|
137
|
+
assert.equal(append?.args[0], "INBOX/Sent");
|
|
154
138
|
assert.deepEqual(append?.args[2], ["\\Seen"]);
|
|
155
139
|
assert.deepEqual(called("outboxMessage.delete")[0]?.args, [
|
|
156
140
|
"cfg-1",
|
|
@@ -194,60 +178,8 @@ describe("handleAppendSentMessage", () => {
|
|
|
194
178
|
assert.match(raw, /^From: alice@example\.com$/m);
|
|
195
179
|
});
|
|
196
180
|
|
|
197
|
-
it("falls back to a conventionally-named Sent folder when no special-use flag is set", async () => {
|
|
198
|
-
h.specialUseSent = null;
|
|
199
|
-
|
|
200
|
-
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
201
|
-
|
|
202
|
-
assert.equal(called("connection.append")[0]?.args[0], "Sent Items");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("files into a Sent folder nested under INBOX when no special-use flag is set", async () => {
|
|
206
|
-
h.specialUseSent = null;
|
|
207
|
-
h.mailboxes = [
|
|
208
|
-
{ mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "/" },
|
|
209
|
-
{
|
|
210
|
-
mailboxId: "sent-mbx",
|
|
211
|
-
fullPath: "INBOX/Sent",
|
|
212
|
-
hierarchyDelimiter: "/",
|
|
213
|
-
},
|
|
214
|
-
{
|
|
215
|
-
mailboxId: "sent-messages-mbx",
|
|
216
|
-
fullPath: "INBOX/Sent Messages",
|
|
217
|
-
hierarchyDelimiter: "/",
|
|
218
|
-
},
|
|
219
|
-
];
|
|
220
|
-
|
|
221
|
-
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
222
|
-
|
|
223
|
-
assert.equal(called("connection.append")[0]?.args[0], "INBOX/Sent");
|
|
224
|
-
assert.deepEqual(called("outboxMessage.delete")[0]?.args, [
|
|
225
|
-
"cfg-1",
|
|
226
|
-
"out-1",
|
|
227
|
-
]);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("files into a nested Sent folder under a non-INBOX prefix and a dot delimiter", async () => {
|
|
231
|
-
h.specialUseSent = null;
|
|
232
|
-
h.mailboxes = [
|
|
233
|
-
{ mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "." },
|
|
234
|
-
{
|
|
235
|
-
mailboxId: "sent-mbx",
|
|
236
|
-
fullPath: "Mail.Sent Items",
|
|
237
|
-
hierarchyDelimiter: ".",
|
|
238
|
-
},
|
|
239
|
-
];
|
|
240
|
-
|
|
241
|
-
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
242
|
-
|
|
243
|
-
assert.equal(called("connection.append")[0]?.args[0], "Mail.Sent Items");
|
|
244
|
-
});
|
|
245
|
-
|
|
246
181
|
it("settles the row as unfiled when the account has no Sent folder at all", async () => {
|
|
247
|
-
h.
|
|
248
|
-
h.mailboxes = [
|
|
249
|
-
{ mailboxId: "inbox-mbx", fullPath: "INBOX", hierarchyDelimiter: "/" },
|
|
250
|
-
];
|
|
182
|
+
h.sentMailbox = null;
|
|
251
183
|
|
|
252
184
|
await handleAppendSentMessage(event, noopLog, 1, deps());
|
|
253
185
|
|
|
@@ -261,7 +193,7 @@ describe("handleAppendSentMessage", () => {
|
|
|
261
193
|
assert.deepEqual(update?.args.slice(0, 2), ["cfg-1", "out-1"]);
|
|
262
194
|
const patch = update?.args[2] as { status: string; lastError: string };
|
|
263
195
|
assert.equal(patch.status, "unfiled");
|
|
264
|
-
assert.match(patch.lastError, /no Sent
|
|
196
|
+
assert.match(patch.lastError, /no folder appointed to the Sent role/);
|
|
265
197
|
});
|
|
266
198
|
|
|
267
199
|
it("skips the append while the outbox row is not yet sent", async () => {
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import
|
|
3
|
-
IMailboxRepository,
|
|
4
|
-
IMailboxSpecialUseRepository,
|
|
5
|
-
} from "@remit/data-ports";
|
|
6
|
-
import { MailboxSpecialUse, OutboxMessageStatus } from "@remit/domain-enums";
|
|
2
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
7
3
|
import type { Logger } from "@remit/logger-lambda";
|
|
8
4
|
import {
|
|
9
5
|
buildMailMessage,
|
|
@@ -12,29 +8,11 @@ import {
|
|
|
12
8
|
import { isAccountDeleted } from "../account-check.js";
|
|
13
9
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
14
10
|
import type { AppendSentMessageEvent } from "../events.js";
|
|
15
|
-
import { resolveSentMailboxByName } from "../sent-mailbox.js";
|
|
16
11
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
17
12
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
18
13
|
|
|
19
|
-
const findSentMailbox = async (
|
|
20
|
-
mailboxSpecialUseService: IMailboxSpecialUseRepository,
|
|
21
|
-
mailboxService: IMailboxRepository,
|
|
22
|
-
accountId: string,
|
|
23
|
-
): Promise<{ mailboxId: string; fullPath: string } | null> => {
|
|
24
|
-
const bySpecialUse = await mailboxSpecialUseService.findBySpecialUse(
|
|
25
|
-
accountId,
|
|
26
|
-
MailboxSpecialUse.Sent,
|
|
27
|
-
);
|
|
28
|
-
if (bySpecialUse) {
|
|
29
|
-
return bySpecialUse;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const mailboxes = await mailboxService.listAllByAccount(accountId);
|
|
33
|
-
return resolveSentMailboxByName(mailboxes);
|
|
34
|
-
};
|
|
35
|
-
|
|
36
14
|
const UNFILED_NO_SENT_MAILBOX =
|
|
37
|
-
"Sent, but not filed: this account has no Sent folder.
|
|
15
|
+
"Sent, but not filed: this account has no folder appointed to the Sent role and none that a Sent folder could be recognised by. Appoint one in the account's folder settings and later messages will be filed there.";
|
|
38
16
|
|
|
39
17
|
const UNFILED_SIGNED_OUT =
|
|
40
18
|
"Sent, but not filed: this account has to be signed in again before a copy can be stored in Sent.";
|
|
@@ -100,7 +78,6 @@ export const handleAppendSentMessage = async (
|
|
|
100
78
|
outboxMessage: outboxMessageService,
|
|
101
79
|
outboxAttachment: outboxAttachmentService,
|
|
102
80
|
mailboxSpecialUse: mailboxSpecialUseService,
|
|
103
|
-
mailbox: mailboxService,
|
|
104
81
|
secrets,
|
|
105
82
|
} = await getClient();
|
|
106
83
|
|
|
@@ -144,11 +121,7 @@ export const handleAppendSentMessage = async (
|
|
|
144
121
|
);
|
|
145
122
|
};
|
|
146
123
|
|
|
147
|
-
const sentMailbox = await findSentMailbox(
|
|
148
|
-
mailboxSpecialUseService,
|
|
149
|
-
mailboxService,
|
|
150
|
-
accountId,
|
|
151
|
-
);
|
|
124
|
+
const sentMailbox = await mailboxSpecialUseService.findSentMailbox(accountId);
|
|
152
125
|
if (!sentMailbox) {
|
|
153
126
|
await settleUnfiled(UNFILED_NO_SENT_MAILBOX);
|
|
154
127
|
return;
|
|
@@ -367,6 +367,67 @@ describe("handleMessageDelete", () => {
|
|
|
367
367
|
);
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
+
// The queue handler `JSON.parse`s the body and casts it to WorkerEvent with
|
|
371
|
+
// no validation, so an event whose `operation` is missing, misspelled or
|
|
372
|
+
// from a newer producer reaches here. It must never be read as an expunge.
|
|
373
|
+
for (const [name, operation] of [
|
|
374
|
+
["missing", undefined],
|
|
375
|
+
["unknown", "trash"],
|
|
376
|
+
["empty", ""],
|
|
377
|
+
] as const) {
|
|
378
|
+
it(`abandons the delete and hands the row back when operation is ${name}`, async () => {
|
|
379
|
+
const malformed = {
|
|
380
|
+
...moveEvent,
|
|
381
|
+
operation,
|
|
382
|
+
} as unknown as MessageDeleteEvent;
|
|
383
|
+
|
|
384
|
+
await handleMessageDelete(malformed, noopLog, deps());
|
|
385
|
+
|
|
386
|
+
assert.equal(
|
|
387
|
+
called("connection.deleteMessages").length,
|
|
388
|
+
0,
|
|
389
|
+
"nothing may be expunged for an operation nobody wrote",
|
|
390
|
+
);
|
|
391
|
+
assert.equal(called("message.delete").length, 0);
|
|
392
|
+
assert.equal(called("threadMessage.delete").length, 0);
|
|
393
|
+
|
|
394
|
+
// The row goes back to the mailbox the server still holds it in, so
|
|
395
|
+
// the failure is visible as the message reappearing rather than as an
|
|
396
|
+
// invisible syncStatus on a row that claims Trash.
|
|
397
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
398
|
+
"msg-1",
|
|
399
|
+
10,
|
|
400
|
+
"src-mbx",
|
|
401
|
+
]);
|
|
402
|
+
assert.deepEqual(called("message.update")[0]?.args[1], {
|
|
403
|
+
status: "active",
|
|
404
|
+
syncStatus: "failed",
|
|
405
|
+
});
|
|
406
|
+
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
407
|
+
uid: 10,
|
|
408
|
+
mailboxId: "src-mbx",
|
|
409
|
+
isDeleted: false,
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
it("refuses to expunge a move-to-trash that names no destination", async () => {
|
|
415
|
+
const destinationless = {
|
|
416
|
+
...moveEvent,
|
|
417
|
+
destinationMailboxId: undefined,
|
|
418
|
+
destinationMailboxPath: undefined,
|
|
419
|
+
} as MessageDeleteEvent;
|
|
420
|
+
|
|
421
|
+
await handleMessageDelete(destinationless, noopLog, deps());
|
|
422
|
+
|
|
423
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
424
|
+
assert.equal(called("message.delete").length, 0);
|
|
425
|
+
assert.deepEqual(called("message.update")[0]?.args[1], {
|
|
426
|
+
status: "active",
|
|
427
|
+
syncStatus: "failed",
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
370
431
|
it("expunges on the server and removes every thread row before the message row", async () => {
|
|
371
432
|
await handleMessageDelete(permanentEvent, noopLog, deps());
|
|
372
433
|
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
IThreadMessageRepository,
|
|
4
4
|
ThreadMessageItem,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
|
-
import { MessageSyncStatus } from "@remit/domain-enums";
|
|
6
|
+
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
7
7
|
import type { Logger } from "@remit/logger-lambda";
|
|
8
8
|
import {
|
|
9
9
|
guardConnectionCursor,
|
|
@@ -56,16 +56,27 @@ export const deleteAllThreadMessagesForMessage = async (
|
|
|
56
56
|
* the caller silently drops the update. Same root cause as PR #186 fixed for
|
|
57
57
|
* `flag-queue.ts`.
|
|
58
58
|
*/
|
|
59
|
+
type ThreadMessageRowState = Pick<
|
|
60
|
+
ThreadMessageItem,
|
|
61
|
+
| "sentDate"
|
|
62
|
+
| "mailboxId"
|
|
63
|
+
| "isRead"
|
|
64
|
+
| "isDeleted"
|
|
65
|
+
| "hasStars"
|
|
66
|
+
| "hasAttachment"
|
|
67
|
+
>;
|
|
68
|
+
|
|
69
|
+
const currentComposites = (threadMessage: ThreadMessageRowState) => ({
|
|
70
|
+
sentDate: threadMessage.sentDate,
|
|
71
|
+
mailboxId: threadMessage.mailboxId,
|
|
72
|
+
isRead: threadMessage.isRead,
|
|
73
|
+
isDeleted: threadMessage.isDeleted,
|
|
74
|
+
hasStars: threadMessage.hasStars,
|
|
75
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
76
|
+
});
|
|
77
|
+
|
|
59
78
|
export const buildThreadMessageTrashUpdate = (
|
|
60
|
-
threadMessage:
|
|
61
|
-
ThreadMessageItem,
|
|
62
|
-
| "sentDate"
|
|
63
|
-
| "mailboxId"
|
|
64
|
-
| "isRead"
|
|
65
|
-
| "isDeleted"
|
|
66
|
-
| "hasStars"
|
|
67
|
-
| "hasAttachment"
|
|
68
|
-
>,
|
|
79
|
+
threadMessage: ThreadMessageRowState,
|
|
69
80
|
newUid: number,
|
|
70
81
|
destinationMailboxId: string,
|
|
71
82
|
) => ({
|
|
@@ -74,14 +85,27 @@ export const buildThreadMessageTrashUpdate = (
|
|
|
74
85
|
mailboxId: destinationMailboxId,
|
|
75
86
|
isDeleted: true,
|
|
76
87
|
},
|
|
77
|
-
composites:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
composites: currentComposites(threadMessage),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The inverse payload: put the thread row back where the message still is on
|
|
93
|
+
* the server. The delete was recorded optimistically, so a delete abandoned
|
|
94
|
+
* before any IMAP write has to hand the row back rather than leave it claiming
|
|
95
|
+
* Trash — an invisible `failed` on a row the user cannot see is the shape of
|
|
96
|
+
* the incident this whole change is about.
|
|
97
|
+
*/
|
|
98
|
+
export const buildThreadMessageMoveRevert = (
|
|
99
|
+
threadMessage: ThreadMessageRowState,
|
|
100
|
+
sourceUid: number,
|
|
101
|
+
sourceMailboxId: string,
|
|
102
|
+
) => ({
|
|
103
|
+
set: {
|
|
104
|
+
uid: sourceUid,
|
|
105
|
+
mailboxId: sourceMailboxId,
|
|
106
|
+
isDeleted: false,
|
|
84
107
|
},
|
|
108
|
+
composites: currentComposites(threadMessage),
|
|
85
109
|
});
|
|
86
110
|
|
|
87
111
|
export interface MessageDeleteDeps {
|
|
@@ -198,7 +222,63 @@ export const handleMessageDelete = async (
|
|
|
198
222
|
);
|
|
199
223
|
await connection.openBox(mailboxPath, false);
|
|
200
224
|
|
|
201
|
-
|
|
225
|
+
// Only an operation that explicitly says so destroys mail. The
|
|
226
|
+
// event is `JSON.parse`d and cast in the queue handler with no
|
|
227
|
+
// validation, so a missing, misspelled or future `operation` must
|
|
228
|
+
// abandon the delete — the "anything that is not move_to_trash is
|
|
229
|
+
// an expunge" inference is the same one that destroyed mail in the
|
|
230
|
+
// service, and an unrecoverable EXPUNGE is not a default.
|
|
231
|
+
const abandonDelete = async (
|
|
232
|
+
reason: string,
|
|
233
|
+
alert: string,
|
|
234
|
+
): Promise<void> => {
|
|
235
|
+
log.error(
|
|
236
|
+
{ alert, accountId, messageId, uid, mailboxPath, operation },
|
|
237
|
+
reason,
|
|
238
|
+
);
|
|
239
|
+
await messageService.updateUid(messageId, uid, mailboxId);
|
|
240
|
+
await messageService.update(messageId, {
|
|
241
|
+
status: MessageStatus.active,
|
|
242
|
+
syncStatus: MessageSyncStatus.failed,
|
|
243
|
+
});
|
|
244
|
+
const threadMessage = await threadMessageService.findByMessageId(
|
|
245
|
+
account.accountConfigId,
|
|
246
|
+
messageId,
|
|
247
|
+
);
|
|
248
|
+
if (!threadMessage) return;
|
|
249
|
+
const args = buildThreadMessageMoveRevert(
|
|
250
|
+
threadMessage,
|
|
251
|
+
uid,
|
|
252
|
+
mailboxId,
|
|
253
|
+
);
|
|
254
|
+
await threadMessageService.update(
|
|
255
|
+
threadMessage.accountConfigId,
|
|
256
|
+
threadMessage.threadMessageId,
|
|
257
|
+
args.set,
|
|
258
|
+
{ composites: args.composites },
|
|
259
|
+
);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (
|
|
263
|
+
operation !== "move_to_trash" &&
|
|
264
|
+
operation !== "permanent_delete"
|
|
265
|
+
) {
|
|
266
|
+
await abandonDelete(
|
|
267
|
+
"Refused to delete: event carries an unrecognized operation",
|
|
268
|
+
"message_delete_unknown_operation",
|
|
269
|
+
);
|
|
270
|
+
} else if (
|
|
271
|
+
operation === "move_to_trash" &&
|
|
272
|
+
(!destinationMailboxPath || !destinationMailboxId)
|
|
273
|
+
) {
|
|
274
|
+
// Defence in depth. `MessageMoveService` always sets both fields,
|
|
275
|
+
// so nothing mints such an event today; the guard exists so that
|
|
276
|
+
// a future producer that forgets one cannot reach the expunge.
|
|
277
|
+
await abandonDelete(
|
|
278
|
+
"Refused to delete: move to trash carries no destination mailbox",
|
|
279
|
+
"message_delete_missing_destination",
|
|
280
|
+
);
|
|
281
|
+
} else if (operation === "move_to_trash" && destinationMailboxPath) {
|
|
202
282
|
// Move to Trash
|
|
203
283
|
const result = await connection.moveMessages(
|
|
204
284
|
[uid],
|
|
@@ -244,7 +324,7 @@ export const handleMessageDelete = async (
|
|
|
244
324
|
});
|
|
245
325
|
}
|
|
246
326
|
} else {
|
|
247
|
-
// Permanent delete
|
|
327
|
+
// Permanent delete — reached only by `operation === "permanent_delete"`.
|
|
248
328
|
await connection.deleteMessages([uid]);
|
|
249
329
|
|
|
250
330
|
// Delete ThreadMessage rows BEFORE the Message row to collapse the
|
package/src/sent-mailbox.test.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
resolveSentMailboxByName,
|
|
5
|
-
type SentMailboxCandidate,
|
|
6
|
-
} from "./sent-mailbox.js";
|
|
7
|
-
|
|
8
|
-
const mailbox = (
|
|
9
|
-
fullPath: string,
|
|
10
|
-
hierarchyDelimiter = "/",
|
|
11
|
-
): SentMailboxCandidate => ({
|
|
12
|
-
mailboxId: `mbx-${fullPath}`,
|
|
13
|
-
fullPath,
|
|
14
|
-
hierarchyDelimiter,
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
describe("resolveSentMailboxByName", () => {
|
|
18
|
-
it("finds a top-level Sent folder", () => {
|
|
19
|
-
const found = resolveSentMailboxByName([mailbox("INBOX"), mailbox("Sent")]);
|
|
20
|
-
|
|
21
|
-
assert.equal(found?.fullPath, "Sent");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("finds a Sent folder nested under INBOX", () => {
|
|
25
|
-
const found = resolveSentMailboxByName([
|
|
26
|
-
mailbox("INBOX"),
|
|
27
|
-
mailbox("INBOX/Archive"),
|
|
28
|
-
mailbox("INBOX/Sent"),
|
|
29
|
-
]);
|
|
30
|
-
|
|
31
|
-
assert.equal(found?.fullPath, "INBOX/Sent");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("finds a nested Sent folder under a non-INBOX prefix with a non-slash delimiter", () => {
|
|
35
|
-
const found = resolveSentMailboxByName([
|
|
36
|
-
mailbox("Mail.Drafts", "."),
|
|
37
|
-
mailbox("Mail.Sent Items", "."),
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
|
-
assert.equal(found?.fullPath, "Mail.Sent Items");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("finds the Gmail Sent folder by its leaf name", () => {
|
|
44
|
-
const found = resolveSentMailboxByName([
|
|
45
|
-
mailbox("INBOX"),
|
|
46
|
-
mailbox("[Gmail]/All Mail"),
|
|
47
|
-
mailbox("[Gmail]/Sent Mail"),
|
|
48
|
-
]);
|
|
49
|
-
|
|
50
|
-
assert.equal(found?.fullPath, "[Gmail]/Sent Mail");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("prefers the shallowest folder when several leaves match", () => {
|
|
54
|
-
const found = resolveSentMailboxByName([
|
|
55
|
-
mailbox("INBOX/Clients/Sent"),
|
|
56
|
-
mailbox("INBOX/Sent"),
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
assert.equal(found?.fullPath, "INBOX/Sent");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("prefers the plain Sent name over the longer variants", () => {
|
|
63
|
-
const found = resolveSentMailboxByName([
|
|
64
|
-
mailbox("INBOX/Sent Messages"),
|
|
65
|
-
mailbox("INBOX/Sent"),
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
assert.equal(found?.fullPath, "INBOX/Sent");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("matches the leaf regardless of case", () => {
|
|
72
|
-
const found = resolveSentMailboxByName([mailbox("INBOX.sent items", ".")]);
|
|
73
|
-
|
|
74
|
-
assert.equal(found?.fullPath, "INBOX.sent items");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("does not match a folder that merely starts with a Sent name", () => {
|
|
78
|
-
const found = resolveSentMailboxByName([
|
|
79
|
-
mailbox("INBOX/Sent Archive 2024"),
|
|
80
|
-
mailbox("INBOX/Sentinel"),
|
|
81
|
-
]);
|
|
82
|
-
|
|
83
|
-
assert.equal(found, null);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("prefers a shallower Sent over a deeper better-named one", () => {
|
|
87
|
-
const found = resolveSentMailboxByName([
|
|
88
|
-
mailbox("INBOX.Sent Items", "."),
|
|
89
|
-
mailbox("INBOX.Trash.Sent", "."),
|
|
90
|
-
]);
|
|
91
|
-
|
|
92
|
-
assert.equal(found?.fullPath, "INBOX.Sent Items");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("resolves a flat namespace that reports no delimiter at all", () => {
|
|
96
|
-
const found = resolveSentMailboxByName([
|
|
97
|
-
mailbox("INBOX", ""),
|
|
98
|
-
mailbox("Sent", ""),
|
|
99
|
-
]);
|
|
100
|
-
|
|
101
|
-
assert.equal(found?.fullPath, "Sent");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("returns null when the account has no Sent folder", () => {
|
|
105
|
-
const found = resolveSentMailboxByName([
|
|
106
|
-
mailbox("INBOX"),
|
|
107
|
-
mailbox("INBOX/Trash"),
|
|
108
|
-
]);
|
|
109
|
-
|
|
110
|
-
assert.equal(found, null);
|
|
111
|
-
});
|
|
112
|
-
});
|
package/src/sent-mailbox.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import type { MailboxItem } from "@remit/data-ports";
|
|
2
|
-
import { resolveMailboxByLeafName } from "@remit/data-ports/mailbox-name";
|
|
3
|
-
|
|
4
|
-
export type SentMailboxCandidate = Pick<
|
|
5
|
-
MailboxItem,
|
|
6
|
-
"mailboxId" | "fullPath" | "hierarchyDelimiter"
|
|
7
|
-
>;
|
|
8
|
-
|
|
9
|
-
const SENT_FOLDER_NAMES = ["sent", "sent items", "sent messages", "sent mail"];
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* The Sent folder by conventional name, for servers that advertise no `\Sent`
|
|
13
|
-
* special-use. Matches the folder's own leaf segment, so it resolves at any
|
|
14
|
-
* depth under any prefix (`INBOX/Sent`, `Mail.Sent Items`, `[Gmail]/Sent Mail`)
|
|
15
|
-
* without knowing which prefixes a server uses.
|
|
16
|
-
*/
|
|
17
|
-
export const resolveSentMailboxByName = (
|
|
18
|
-
mailboxes: SentMailboxCandidate[],
|
|
19
|
-
): SentMailboxCandidate | null =>
|
|
20
|
-
resolveMailboxByLeafName(mailboxes, SENT_FOLDER_NAMES);
|