@remit/mailbox-service 0.0.59 → 0.0.61
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/body-sync-placement-flags.test.ts +2 -6
- package/src/body-sync-placement-writeonce.test.ts +2 -3
- package/src/body-sync.ts +4 -9
- package/src/index.ts +1 -0
- package/src/message-delete-junk-reconcile.test.ts +4 -2
- package/src/message-delete-no-trash.test.ts +168 -0
- package/src/message-move-copy.test.ts +2 -0
- package/src/message-move-empty-trash.test.ts +100 -0
- package/src/message-move.ts +51 -14
- package/src/message-sync-address-spoof.test.ts +5 -1
- package/src/message-sync-category.test.ts +5 -1
- package/src/message-sync-changedsince.test.ts +2 -0
- package/src/message-sync-flags.test.ts +2 -0
- package/src/message-sync-junk-harvest.test.ts +52 -52
- package/src/message-sync-quarantine.test.ts +2 -0
- package/src/message-sync.ts +69 -15
- package/src/test-helpers/folder-roles.ts +34 -0
package/package.json
CHANGED
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
IThreadMessageRepository,
|
|
20
20
|
UpdateMessageInput,
|
|
21
21
|
} from "@remit/data-ports";
|
|
22
|
-
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
23
22
|
import type { StorageService } from "@remit/storage-service";
|
|
24
23
|
import type { PlacementConfig } from "./body-sync.js";
|
|
25
24
|
import { BodySyncService } from "./body-sync.js";
|
|
@@ -116,11 +115,8 @@ const buildHarness = (
|
|
|
116
115
|
} as unknown as IEnvelopeRepository;
|
|
117
116
|
|
|
118
117
|
const mailboxSpecialUseService = {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (specialUse === MailboxSpecialUse.Archive) return MAILBOXES.archive;
|
|
122
|
-
return null;
|
|
123
|
-
},
|
|
118
|
+
findJunkMailbox: async () => MAILBOXES.junk,
|
|
119
|
+
findArchiveMailbox: async () => MAILBOXES.archive,
|
|
124
120
|
findInboxMailbox: async () => MAILBOXES.inbox,
|
|
125
121
|
} as unknown as IMailboxSpecialUseRepository;
|
|
126
122
|
|
|
@@ -30,7 +30,6 @@ import type {
|
|
|
30
30
|
MessageItem,
|
|
31
31
|
UpdateMessageInput,
|
|
32
32
|
} from "@remit/data-ports";
|
|
33
|
-
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
34
33
|
import type { StorageService } from "@remit/storage-service";
|
|
35
34
|
import type { PlacementConfig } from "./body-sync.js";
|
|
36
35
|
import { BodySyncService } from "./body-sync.js";
|
|
@@ -129,8 +128,8 @@ const buildHarness = (
|
|
|
129
128
|
} as unknown as IEnvelopeRepository;
|
|
130
129
|
|
|
131
130
|
const mailboxSpecialUseService = {
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
findJunkMailbox: async () => MAILBOXES.junk,
|
|
132
|
+
findArchiveMailbox: async () => null,
|
|
134
133
|
findInboxMailbox: async () => MAILBOXES.inbox,
|
|
135
134
|
} as unknown as IMailboxSpecialUseRepository;
|
|
136
135
|
|
package/src/body-sync.ts
CHANGED
|
@@ -14,7 +14,6 @@ import { NotFoundError } from "@remit/data-ports/errors";
|
|
|
14
14
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
15
15
|
import { isBulkSender } from "@remit/data-ports/wellknown";
|
|
16
16
|
import {
|
|
17
|
-
MailboxSpecialUse,
|
|
18
17
|
MessageCategory,
|
|
19
18
|
PlacementAction,
|
|
20
19
|
PlacementConfidence,
|
|
@@ -1477,10 +1476,8 @@ export class BodySyncService {
|
|
|
1477
1476
|
// against the same signals that placed it in the first place.
|
|
1478
1477
|
if (hasDecidedPlacement(message.placementDecidedAt)) return {};
|
|
1479
1478
|
|
|
1480
|
-
const junkMailbox =
|
|
1481
|
-
accountId
|
|
1482
|
-
MailboxSpecialUse.Junk,
|
|
1483
|
-
);
|
|
1479
|
+
const junkMailbox =
|
|
1480
|
+
await mailboxSpecialUseService.findJunkMailbox(accountId);
|
|
1484
1481
|
const inboxMailbox =
|
|
1485
1482
|
await mailboxSpecialUseService.findInboxMailbox(accountId);
|
|
1486
1483
|
|
|
@@ -1612,10 +1609,8 @@ export class BodySyncService {
|
|
|
1612
1609
|
): Promise<PlacementOutcome> {
|
|
1613
1610
|
if (!autoArchive) return {};
|
|
1614
1611
|
|
|
1615
|
-
const archiveMailbox =
|
|
1616
|
-
accountId
|
|
1617
|
-
MailboxSpecialUse.Archive,
|
|
1618
|
-
);
|
|
1612
|
+
const archiveMailbox =
|
|
1613
|
+
await mailboxSpecialUseService.findArchiveMailbox(accountId);
|
|
1619
1614
|
if (!archiveMailbox || message.mailboxId === archiveMailbox.mailboxId) {
|
|
1620
1615
|
return {};
|
|
1621
1616
|
}
|
package/src/index.ts
CHANGED
|
@@ -98,10 +98,12 @@ describe("deleting a message re-asks what its senders stand on", () => {
|
|
|
98
98
|
assert.deepEqual(reconciled, []);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
it("asks nothing when the account has no Trash folder", async () => {
|
|
101
|
+
it("asks nothing when the account has no Trash folder, because the delete is refused", async () => {
|
|
102
102
|
const { service, reconciled } = buildWorld(false);
|
|
103
103
|
|
|
104
|
-
await
|
|
104
|
+
await assert.rejects(() =>
|
|
105
|
+
service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
|
|
106
|
+
);
|
|
105
107
|
|
|
106
108
|
assert.deepEqual(reconciled, []);
|
|
107
109
|
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IAddressRepository,
|
|
5
|
+
IMailboxRepository,
|
|
6
|
+
IMailboxSpecialUseRepository,
|
|
7
|
+
IMessageRepository,
|
|
8
|
+
IThreadMessageRepository,
|
|
9
|
+
} from "@remit/data-ports";
|
|
10
|
+
import {
|
|
11
|
+
type MessageMoveConfig,
|
|
12
|
+
MessageMoveService,
|
|
13
|
+
NoTrashMailboxError,
|
|
14
|
+
} from "./message-move.js";
|
|
15
|
+
|
|
16
|
+
const ACCOUNT = "acc-1";
|
|
17
|
+
const ACCOUNT_CONFIG = "cfg-1";
|
|
18
|
+
const INBOX = "mbx-inbox";
|
|
19
|
+
const TRASH = "mbx-trash";
|
|
20
|
+
const MESSAGE_ID = "msg-1";
|
|
21
|
+
|
|
22
|
+
interface EnqueuedEvent {
|
|
23
|
+
operation: string;
|
|
24
|
+
destinationMailboxPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const buildWorld = (
|
|
28
|
+
trash: { mailboxId: string; fullPath: string } | null,
|
|
29
|
+
startingMailboxId = INBOX,
|
|
30
|
+
) => {
|
|
31
|
+
const patches: Array<Record<string, unknown>> = [];
|
|
32
|
+
const events: EnqueuedEvent[] = [];
|
|
33
|
+
const threadMessageDeletes: string[] = [];
|
|
34
|
+
|
|
35
|
+
const message = {
|
|
36
|
+
messageId: MESSAGE_ID,
|
|
37
|
+
mailboxId: startingMailboxId,
|
|
38
|
+
uid: 337,
|
|
39
|
+
status: "active",
|
|
40
|
+
syncStatus: "synced",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const messageService = {
|
|
44
|
+
get: async () => [message],
|
|
45
|
+
update: async (_id: string, patch: Record<string, unknown>) => {
|
|
46
|
+
patches.push(patch);
|
|
47
|
+
return Object.assign(message, patch);
|
|
48
|
+
},
|
|
49
|
+
updateForMove: async (_id: string, patch: Record<string, unknown>) => {
|
|
50
|
+
patches.push(patch);
|
|
51
|
+
return Object.assign(message, patch);
|
|
52
|
+
},
|
|
53
|
+
} as unknown as IMessageRepository;
|
|
54
|
+
|
|
55
|
+
const threadMessageService = {
|
|
56
|
+
findAllByMessageId: async () => [],
|
|
57
|
+
getByMessageId: async () => ({
|
|
58
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
59
|
+
threadMessageId: "tm-1",
|
|
60
|
+
messageId: MESSAGE_ID,
|
|
61
|
+
mailboxId: startingMailboxId,
|
|
62
|
+
}),
|
|
63
|
+
update: async () => {},
|
|
64
|
+
delete: async (_cfg: string, id: string) => {
|
|
65
|
+
threadMessageDeletes.push(id);
|
|
66
|
+
},
|
|
67
|
+
} as unknown as IThreadMessageRepository;
|
|
68
|
+
|
|
69
|
+
const mailboxService = {
|
|
70
|
+
get: async () => [
|
|
71
|
+
{ mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
72
|
+
{ mailboxId: TRASH, fullPath: "INBOX/Trash", accountId: ACCOUNT },
|
|
73
|
+
],
|
|
74
|
+
} as unknown as IMailboxRepository;
|
|
75
|
+
|
|
76
|
+
const mailboxSpecialUseService = {
|
|
77
|
+
findTrashMailbox: async () => trash,
|
|
78
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
79
|
+
|
|
80
|
+
const addressService = {
|
|
81
|
+
reconcileJunkOnlyForMessage: async () => {},
|
|
82
|
+
} as unknown as IAddressRepository;
|
|
83
|
+
|
|
84
|
+
const config: MessageMoveConfig = {
|
|
85
|
+
messageService,
|
|
86
|
+
mailboxService,
|
|
87
|
+
mailboxSpecialUseService,
|
|
88
|
+
threadMessageService,
|
|
89
|
+
addressService,
|
|
90
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const service = new MessageMoveService(config);
|
|
94
|
+
(
|
|
95
|
+
service as unknown as {
|
|
96
|
+
enqueueEventsBatch: (batch: EnqueuedEvent[]) => Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
).enqueueEventsBatch = async (batch) => {
|
|
99
|
+
events.push(...batch);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return { service, patches, events, message };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
describe("delete only expunges when it was asked to", () => {
|
|
106
|
+
it("moves to a Trash the account resolves under a nested path", async () => {
|
|
107
|
+
const { service, events, message } = buildWorld({
|
|
108
|
+
mailboxId: TRASH,
|
|
109
|
+
fullPath: "INBOX/Trash",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
events.map((event) => event.operation),
|
|
116
|
+
["move_to_trash"],
|
|
117
|
+
);
|
|
118
|
+
assert.equal(events[0].destinationMailboxPath, "INBOX/Trash");
|
|
119
|
+
assert.equal(message.mailboxId, TRASH);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("refuses, and touches nothing, when no Trash resolves", async () => {
|
|
123
|
+
const { service, patches, events, message } = buildWorld(null);
|
|
124
|
+
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
() => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
|
|
127
|
+
(error: unknown) =>
|
|
128
|
+
error instanceof NoTrashMailboxError &&
|
|
129
|
+
error.statusCode === 409 &&
|
|
130
|
+
error.message.includes("no Trash folder"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
assert.deepEqual(events, []);
|
|
134
|
+
assert.deepEqual(patches, []);
|
|
135
|
+
assert.equal(message.mailboxId, INBOX);
|
|
136
|
+
assert.equal(message.status, "active");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("expunges only when the caller asked for a permanent delete", async () => {
|
|
140
|
+
const { service, events } = buildWorld({
|
|
141
|
+
mailboxId: TRASH,
|
|
142
|
+
fullPath: "INBOX/Trash",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT, {
|
|
146
|
+
permanent: true,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
events.map((event) => event.operation),
|
|
151
|
+
["permanent_delete"],
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("expunges a message already in Trash, which the dialog asks as such", async () => {
|
|
156
|
+
const { service, events } = buildWorld(
|
|
157
|
+
{ mailboxId: TRASH, fullPath: "INBOX/Trash" },
|
|
158
|
+
TRASH,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(
|
|
164
|
+
events.map((event) => event.operation),
|
|
165
|
+
["permanent_delete"],
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -15,6 +15,7 @@ import { MailboxCursorState } from "@remit/domain-enums";
|
|
|
15
15
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
16
16
|
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
17
17
|
import { MessageSyncService } from "./message-sync.js";
|
|
18
|
+
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
18
19
|
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
19
20
|
|
|
20
21
|
const stubAddressService = (): IAddressRepository =>
|
|
@@ -304,6 +305,7 @@ const buildSyncOverDestination = (
|
|
|
304
305
|
return new MessageSyncService(
|
|
305
306
|
connectionFactory,
|
|
306
307
|
mailboxService,
|
|
308
|
+
noFolderRoles,
|
|
307
309
|
world.messageService,
|
|
308
310
|
{} as IEnvelopeRepository,
|
|
309
311
|
{} as IAddressRepository,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empty Trash is an EXPUNGE with no undo, so it resolves its folder through
|
|
3
|
+
* `findConfirmedTrashMailbox` — what the user appointed, or what the server
|
|
4
|
+
* flagged `\Trash`. The name proposal that serves every other lookup is a
|
|
5
|
+
* guess, and a wrong guess here destroys mail (#837, audit #841).
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import type {
|
|
10
|
+
IAddressRepository,
|
|
11
|
+
IMailboxRepository,
|
|
12
|
+
IMailboxSpecialUseRepository,
|
|
13
|
+
IMessageRepository,
|
|
14
|
+
IThreadMessageRepository,
|
|
15
|
+
} from "@remit/data-ports";
|
|
16
|
+
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
17
|
+
|
|
18
|
+
const ACCOUNT = "acc-1";
|
|
19
|
+
const ACCOUNT_CONFIG = "cfg-1";
|
|
20
|
+
const DELETED_FOLDER = "mbx-deleted";
|
|
21
|
+
const REAL_TRASH = "mbx-trash";
|
|
22
|
+
|
|
23
|
+
const buildWorld = (
|
|
24
|
+
confirmedTrash: { mailboxId: string; fullPath: string } | null,
|
|
25
|
+
) => {
|
|
26
|
+
const emptied: string[] = [];
|
|
27
|
+
const messagesByMailbox = new Map<string, { messageId: string }[]>([
|
|
28
|
+
[DELETED_FOLDER, [{ messageId: "keepsake-1" }]],
|
|
29
|
+
[REAL_TRASH, [{ messageId: "junk-1" }]],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const messageService = {
|
|
33
|
+
listAllByMailbox: async (mailboxId: string) => {
|
|
34
|
+
emptied.push(mailboxId);
|
|
35
|
+
return messagesByMailbox.get(mailboxId) ?? [];
|
|
36
|
+
},
|
|
37
|
+
update: async () => {},
|
|
38
|
+
} as unknown as IMessageRepository;
|
|
39
|
+
|
|
40
|
+
const threadMessageService = {
|
|
41
|
+
getByMessageId: async () => ({
|
|
42
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
43
|
+
threadMessageId: "tm-1",
|
|
44
|
+
sentDate: 1_700_000_000_000,
|
|
45
|
+
}),
|
|
46
|
+
update: async () => {},
|
|
47
|
+
} as unknown as IThreadMessageRepository;
|
|
48
|
+
|
|
49
|
+
const config: MessageMoveConfig = {
|
|
50
|
+
messageService,
|
|
51
|
+
mailboxService: {} as unknown as IMailboxRepository,
|
|
52
|
+
addressService: {
|
|
53
|
+
reconcileJunkOnlyForMessage: async () => {},
|
|
54
|
+
} as unknown as IAddressRepository,
|
|
55
|
+
mailboxSpecialUseService: {
|
|
56
|
+
// A folder merely named `Deleted` is what the name proposal would
|
|
57
|
+
// return; the confirmed lookup is what this path asks for.
|
|
58
|
+
findTrashMailbox: async () => ({
|
|
59
|
+
mailboxId: DELETED_FOLDER,
|
|
60
|
+
fullPath: "Deleted",
|
|
61
|
+
}),
|
|
62
|
+
findConfirmedTrashMailbox: async () => confirmedTrash,
|
|
63
|
+
} as unknown as IMailboxSpecialUseRepository,
|
|
64
|
+
threadMessageService,
|
|
65
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const service = new MessageMoveService(config);
|
|
69
|
+
(
|
|
70
|
+
service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
|
|
71
|
+
).sqs = { send: async () => ({}) };
|
|
72
|
+
|
|
73
|
+
return { service, emptied };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("MessageMoveService.emptyTrash", () => {
|
|
77
|
+
it("empties the appointed Trash, never the user folder called Deleted", async () => {
|
|
78
|
+
const { service, emptied } = buildWorld({
|
|
79
|
+
mailboxId: REAL_TRASH,
|
|
80
|
+
fullPath: "[Gmail]/Trash",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
84
|
+
|
|
85
|
+
assert.deepEqual(emptied, [REAL_TRASH]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("refuses and names the remedy when no folder is appointed or flagged", async () => {
|
|
89
|
+
const { service, emptied } = buildWorld(null);
|
|
90
|
+
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
|
|
93
|
+
/Appoint one under Settings/,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Nothing was read, so nothing was marked for deletion and no expunge
|
|
97
|
+
// was enqueued: an unresolved Trash stops the operation dead.
|
|
98
|
+
assert.deepEqual(emptied, []);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/message-move.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type {
|
|
|
11
11
|
IMessageRepository,
|
|
12
12
|
IThreadMessageRepository,
|
|
13
13
|
} from "@remit/data-ports";
|
|
14
|
+
import { HTTPError } from "@remit/data-ports/errors";
|
|
15
|
+
import { NO_TRASH_FOLDER_REASON } from "@remit/data-ports/folder-role";
|
|
14
16
|
import { deriveCopyMessageId } from "@remit/data-ports/id";
|
|
15
17
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
16
18
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
@@ -103,6 +105,22 @@ export interface MessageMoveConfig {
|
|
|
103
105
|
logger?: MessageMoveLogger;
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The account resolves no Trash folder, so a move-to-Trash delete has nowhere
|
|
110
|
+
* to put the mail and an Empty Trash has nothing it may empty. A designed,
|
|
111
|
+
* user-facing outcome carrying its own text and a 409 the backend returns
|
|
112
|
+
* verbatim: the absence of a Trash folder is a blocker the user resolves by
|
|
113
|
+
* appointing one, never a licence to expunge.
|
|
114
|
+
*/
|
|
115
|
+
export class NoTrashMailboxError extends HTTPError {
|
|
116
|
+
name = "NoTrashMailboxError";
|
|
117
|
+
statusCode = 409;
|
|
118
|
+
|
|
119
|
+
constructor() {
|
|
120
|
+
super(NO_TRASH_FOLDER_REASON);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
/**
|
|
107
125
|
* Options for delete operations
|
|
108
126
|
*/
|
|
@@ -191,9 +209,26 @@ export class MessageMoveService {
|
|
|
191
209
|
);
|
|
192
210
|
const mailboxMap = new Map(mailboxes.map((m) => [m.mailboxId, m]));
|
|
193
211
|
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
// A permanent delete is only ever what the caller explicitly asked for.
|
|
213
|
+
// Everything else is a move to Trash, and a Trash folder that does not
|
|
214
|
+
// resolve blocks it — the user was asked "move to Trash?" and expunging
|
|
215
|
+
// their mail instead is unrecoverable and answers a question nobody put
|
|
216
|
+
// to them. Thrown before any local write or event, so the server and the
|
|
217
|
+
// projection are both left untouched.
|
|
218
|
+
const wantsPermanentDelete =
|
|
219
|
+
options.permanent === true || options.toTrash === false;
|
|
220
|
+
|
|
221
|
+
const trashMailbox = wantsPermanentDelete
|
|
222
|
+
? null
|
|
223
|
+
: await this.mailboxSpecialUseService.findTrashMailbox(accountId);
|
|
224
|
+
|
|
225
|
+
if (!wantsPermanentDelete && !trashMailbox) {
|
|
226
|
+
this.log.error(
|
|
227
|
+
{ accountId, messageCount: messages.length },
|
|
228
|
+
"Refused to delete: account resolves no Trash mailbox",
|
|
229
|
+
);
|
|
230
|
+
throw new NoTrashMailboxError();
|
|
231
|
+
}
|
|
197
232
|
|
|
198
233
|
// Group messages by operation type
|
|
199
234
|
const moveToTrashMessages: Array<{
|
|
@@ -212,12 +247,8 @@ export class MessageMoveService {
|
|
|
212
247
|
if (!sourceMailbox) continue;
|
|
213
248
|
|
|
214
249
|
const isInTrash =
|
|
215
|
-
trashMailbox && message.mailboxId === trashMailbox.mailboxId;
|
|
216
|
-
const shouldMoveToTrash =
|
|
217
|
-
options.toTrash !== false &&
|
|
218
|
-
!options.permanent &&
|
|
219
|
-
!isInTrash &&
|
|
220
|
-
trashMailbox;
|
|
250
|
+
trashMailbox !== null && message.mailboxId === trashMailbox.mailboxId;
|
|
251
|
+
const shouldMoveToTrash = trashMailbox !== null && !isInTrash;
|
|
221
252
|
|
|
222
253
|
const entry = {
|
|
223
254
|
messageId: message.messageId,
|
|
@@ -228,7 +259,7 @@ export class MessageMoveService {
|
|
|
228
259
|
},
|
|
229
260
|
};
|
|
230
261
|
|
|
231
|
-
if (shouldMoveToTrash
|
|
262
|
+
if (shouldMoveToTrash) {
|
|
232
263
|
moveToTrashMessages.push(entry);
|
|
233
264
|
} else {
|
|
234
265
|
permanentDeleteMessages.push(entry);
|
|
@@ -618,19 +649,25 @@ export class MessageMoveService {
|
|
|
618
649
|
};
|
|
619
650
|
|
|
620
651
|
/**
|
|
621
|
-
* Empty the Trash mailbox
|
|
652
|
+
* Empty the Trash mailbox: an EXPUNGE of everything in it, with no undo.
|
|
622
653
|
*
|
|
623
|
-
*
|
|
654
|
+
* Resolved through `findConfirmedTrashMailbox`, so the folder is the one the
|
|
655
|
+
* user appointed or the one the server flagged \Trash — never one that
|
|
656
|
+
* merely reads like a trash folder. A user with an ordinary folder called
|
|
657
|
+
* `Deleted` would otherwise lose its contents permanently, and on a
|
|
658
|
+
* `.`-delimited server the old whole-path name match resolved nothing at all
|
|
659
|
+
* and the operation reported success over an empty count. Unresolved is a
|
|
660
|
+
* refusal that names the remedy, not a silent no-op.
|
|
624
661
|
*/
|
|
625
662
|
emptyTrash = async (
|
|
626
663
|
accountConfigId: string,
|
|
627
664
|
accountId: string,
|
|
628
665
|
): Promise<void> => {
|
|
629
666
|
const trashMailbox =
|
|
630
|
-
await this.mailboxSpecialUseService.
|
|
667
|
+
await this.mailboxSpecialUseService.findConfirmedTrashMailbox(accountId);
|
|
631
668
|
|
|
632
669
|
if (!trashMailbox) {
|
|
633
|
-
throw new
|
|
670
|
+
throw new NoTrashMailboxError();
|
|
634
671
|
}
|
|
635
672
|
|
|
636
673
|
// Get all messages in Trash
|
|
@@ -14,7 +14,8 @@ import type {
|
|
|
14
14
|
ThreadMessageItem,
|
|
15
15
|
} from "@remit/data-ports";
|
|
16
16
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
17
|
-
import { MessageSyncService } from "./message-sync.js";
|
|
17
|
+
import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
|
|
18
|
+
import { NO_FOLDER_ROLES, noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
18
19
|
import type { ImapAddress, ImapEnvelope, ImapMessage } from "./types.js";
|
|
19
20
|
|
|
20
21
|
const stub = <T>(): T => ({}) as T;
|
|
@@ -76,6 +77,7 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
|
|
|
76
77
|
const service = new MessageSyncService(
|
|
77
78
|
stub<ManagedConnectionFactory>(),
|
|
78
79
|
stub<IMailboxRepository>(),
|
|
80
|
+
noFolderRoles,
|
|
79
81
|
messageService,
|
|
80
82
|
envelopeService,
|
|
81
83
|
addressService,
|
|
@@ -98,6 +100,7 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
|
|
|
98
100
|
accountId: string,
|
|
99
101
|
accountConfigId: string,
|
|
100
102
|
msg: ImapMessage,
|
|
103
|
+
roles: AccountFolderRoles,
|
|
101
104
|
) => Promise<unknown>;
|
|
102
105
|
}
|
|
103
106
|
).saveMessage(
|
|
@@ -105,6 +108,7 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
|
|
|
105
108
|
"acct-1",
|
|
106
109
|
"cfg-1",
|
|
107
110
|
msg,
|
|
111
|
+
NO_FOLDER_ROLES,
|
|
108
112
|
);
|
|
109
113
|
|
|
110
114
|
return saved;
|
|
@@ -31,7 +31,8 @@ import type {
|
|
|
31
31
|
} from "@remit/data-ports";
|
|
32
32
|
import { MessageCategory } from "@remit/domain-enums";
|
|
33
33
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
34
|
-
import { MessageSyncService } from "./message-sync.js";
|
|
34
|
+
import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
|
|
35
|
+
import { NO_FOLDER_ROLES, noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
35
36
|
import type { ImapMessage } from "./types.js";
|
|
36
37
|
|
|
37
38
|
const envelope = {
|
|
@@ -97,6 +98,7 @@ const saveIntoMailbox = async (
|
|
|
97
98
|
const service = new MessageSyncService(
|
|
98
99
|
stub<ManagedConnectionFactory>(),
|
|
99
100
|
stub<IMailboxRepository>(),
|
|
101
|
+
noFolderRoles,
|
|
100
102
|
messageService,
|
|
101
103
|
envelopeService,
|
|
102
104
|
addressService,
|
|
@@ -110,6 +112,7 @@ const saveIntoMailbox = async (
|
|
|
110
112
|
accountId: string,
|
|
111
113
|
accountConfigId: string,
|
|
112
114
|
msg: ImapMessage,
|
|
115
|
+
roles: AccountFolderRoles,
|
|
113
116
|
) => Promise<unknown>;
|
|
114
117
|
}
|
|
115
118
|
).saveMessage(
|
|
@@ -117,6 +120,7 @@ const saveIntoMailbox = async (
|
|
|
117
120
|
"acct-1",
|
|
118
121
|
"cfg-1",
|
|
119
122
|
imapMessage,
|
|
123
|
+
NO_FOLDER_ROLES,
|
|
120
124
|
);
|
|
121
125
|
|
|
122
126
|
assert.equal(inputs.length, 1);
|
|
@@ -23,6 +23,7 @@ import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
|
23
23
|
import type { FlagPushService } from "./flag-push.js";
|
|
24
24
|
import { FlagQueueService } from "./flag-queue.js";
|
|
25
25
|
import { MessageSyncService } from "./message-sync.js";
|
|
26
|
+
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
26
27
|
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
27
28
|
|
|
28
29
|
const ACCOUNT_ID = "acc-1";
|
|
@@ -267,6 +268,7 @@ const buildHarness = (options: HarnessOptions): Harness => {
|
|
|
267
268
|
const service = new MessageSyncService(
|
|
268
269
|
connectionFactory,
|
|
269
270
|
mailboxService,
|
|
271
|
+
noFolderRoles,
|
|
270
272
|
{} as IMessageRepository,
|
|
271
273
|
{} as IEnvelopeRepository,
|
|
272
274
|
addressRepository,
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
import { MessageCategory, MessageSystemFlag } from "@remit/domain-enums";
|
|
13
13
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
14
14
|
import { MessageSyncService } from "./message-sync.js";
|
|
15
|
+
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
15
16
|
import type { ImapEnvelope } from "./types.js";
|
|
16
17
|
|
|
17
18
|
// #44: initial sync hardcoded `hasStars: false` on row create, so mail flagged
|
|
@@ -83,6 +84,7 @@ const createThreadWithFlags = async (
|
|
|
83
84
|
const service = new MessageSyncService(
|
|
84
85
|
stub<ManagedConnectionFactory>(),
|
|
85
86
|
stub<IMailboxRepository>(),
|
|
87
|
+
noFolderRoles,
|
|
86
88
|
stub<IMessageRepository>(),
|
|
87
89
|
stub<IEnvelopeRepository>(),
|
|
88
90
|
stub<IAddressRepository>(),
|
|
@@ -13,9 +13,13 @@ import type {
|
|
|
13
13
|
MessageItem,
|
|
14
14
|
ThreadMessageItem,
|
|
15
15
|
} from "@remit/data-ports";
|
|
16
|
-
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
17
16
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
18
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type AccountFolderRoles,
|
|
19
|
+
addressSightingIn,
|
|
20
|
+
MessageSyncService,
|
|
21
|
+
} from "./message-sync.js";
|
|
22
|
+
import { folderRoles } from "./test-helpers/folder-roles.js";
|
|
19
23
|
import type { ImapEnvelope, ImapMessage } from "./types.js";
|
|
20
24
|
|
|
21
25
|
const stub = <T>(): T => ({}) as T;
|
|
@@ -41,17 +45,21 @@ interface Saved {
|
|
|
41
45
|
reconciled: string[];
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
const mailboxAt = (
|
|
45
|
-
fullPath: string,
|
|
46
|
-
specialUse?: MailboxItem["specialUse"],
|
|
47
|
-
): MailboxItem =>
|
|
48
|
+
const mailboxAt = (fullPath: string): MailboxItem =>
|
|
48
49
|
({
|
|
50
|
+
mailboxId: `mbx-${fullPath}`,
|
|
49
51
|
fullPath,
|
|
50
52
|
hierarchyDelimiter: "/",
|
|
51
|
-
specialUse,
|
|
52
53
|
}) as MailboxItem;
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Save one message into `mailbox`, with the account holding `role` in that same
|
|
57
|
+
* folder. `undefined` is an account whose Junk and Trash are elsewhere.
|
|
58
|
+
*/
|
|
59
|
+
const save = async (
|
|
60
|
+
mailbox: MailboxItem,
|
|
61
|
+
role?: "Junk" | "Trash",
|
|
62
|
+
): Promise<Saved> => {
|
|
55
63
|
const saved: Saved = {
|
|
56
64
|
correspondents: [],
|
|
57
65
|
junk: [],
|
|
@@ -98,6 +106,13 @@ const save = async (mailbox: MailboxItem): Promise<Saved> => {
|
|
|
98
106
|
const service = new MessageSyncService(
|
|
99
107
|
stub<ManagedConnectionFactory>(),
|
|
100
108
|
stub<IMailboxRepository>(),
|
|
109
|
+
folderRoles(
|
|
110
|
+
role === "Junk"
|
|
111
|
+
? { junkMailboxId: mailbox.mailboxId }
|
|
112
|
+
: role === "Trash"
|
|
113
|
+
? { trashMailboxId: mailbox.mailboxId }
|
|
114
|
+
: {},
|
|
115
|
+
),
|
|
101
116
|
messageService,
|
|
102
117
|
envelopeService,
|
|
103
118
|
addressService,
|
|
@@ -120,9 +135,13 @@ const save = async (mailbox: MailboxItem): Promise<Saved> => {
|
|
|
120
135
|
accountId: string,
|
|
121
136
|
accountConfigId: string,
|
|
122
137
|
msg: ImapMessage,
|
|
138
|
+
roles: AccountFolderRoles,
|
|
123
139
|
) => Promise<unknown>;
|
|
124
140
|
}
|
|
125
|
-
).saveMessage(mailbox, "acct-1", "cfg-1", msg
|
|
141
|
+
).saveMessage(mailbox, "acct-1", "cfg-1", msg, {
|
|
142
|
+
junkMailboxId: role === "Junk" ? mailbox.mailboxId : null,
|
|
143
|
+
trashMailboxId: role === "Trash" ? mailbox.mailboxId : null,
|
|
144
|
+
});
|
|
126
145
|
|
|
127
146
|
return saved;
|
|
128
147
|
};
|
|
@@ -133,42 +152,32 @@ const emails = (inputs: Array<{ normalizedEmail: string }>): string[] =>
|
|
|
133
152
|
const BOTH = ["sales@pharma.example", "victim@ischen.nl"];
|
|
134
153
|
|
|
135
154
|
describe("what a mailbox says about the addresses on its messages", () => {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
specialUse,
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("reads the special-use designation", () => {
|
|
143
|
-
assert.equal(
|
|
144
|
-
addressSightingIn(at("INBOX/Spam", [MailboxSpecialUse.Junk])),
|
|
145
|
-
"junk",
|
|
146
|
-
);
|
|
147
|
-
assert.equal(addressSightingIn(at("INBOX")), "correspondent");
|
|
155
|
+
const ROLES = (junk: string | null, trash: string | null) => ({
|
|
156
|
+
junkMailboxId: junk,
|
|
157
|
+
trashMailboxId: trash,
|
|
148
158
|
});
|
|
149
159
|
|
|
150
|
-
it("
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
it("reads the folder the account appointed, not the folder’s name", () => {
|
|
161
|
+
// The account appointed `INBOX/Rubbish` as Junk. A second folder called
|
|
162
|
+
// `Spam` is an ordinary folder, and its senders are correspondents —
|
|
163
|
+
// a role belongs to one folder (RFC 032), so a name cannot claim it.
|
|
164
|
+
const roles = ROLES("mbx-rubbish", null);
|
|
165
|
+
assert.equal(addressSightingIn("mbx-rubbish", roles), "junk");
|
|
166
|
+
assert.equal(addressSightingIn("mbx-spam", roles), "correspondent");
|
|
154
167
|
});
|
|
155
168
|
|
|
156
|
-
it("
|
|
157
|
-
assert.equal(addressSightingIn(at("INBOX/Spam")), "junk");
|
|
158
|
-
assert.equal(addressSightingIn(at("INBOX/Junk E-mail")), "junk");
|
|
169
|
+
it("keeps a message in Trash from deciding either way", () => {
|
|
159
170
|
assert.equal(
|
|
160
|
-
addressSightingIn(
|
|
161
|
-
"
|
|
171
|
+
addressSightingIn("mbx-trash", ROLES(null, "mbx-trash")),
|
|
172
|
+
"discarded",
|
|
162
173
|
);
|
|
163
174
|
});
|
|
164
175
|
|
|
165
|
-
it("
|
|
166
|
-
assert.equal(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
assert.equal(addressSightingIn({ fullPath: "Spam" }), "junk");
|
|
171
|
-
assert.equal(addressSightingIn({ fullPath: "INBOX" }), "correspondent");
|
|
176
|
+
it("reads an account with neither role as all correspondents", () => {
|
|
177
|
+
assert.equal(
|
|
178
|
+
addressSightingIn("mbx-anything", ROLES(null, null)),
|
|
179
|
+
"correspondent",
|
|
180
|
+
);
|
|
172
181
|
});
|
|
173
182
|
|
|
174
183
|
it("harvests every envelope address of an ordinary message", async () => {
|
|
@@ -180,29 +189,20 @@ describe("what a mailbox says about the addresses on its messages", () => {
|
|
|
180
189
|
});
|
|
181
190
|
|
|
182
191
|
it("withholds every envelope address of a message in Junk", async () => {
|
|
183
|
-
const saved = await save(mailboxAt("INBOX/
|
|
192
|
+
const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
|
|
184
193
|
|
|
185
194
|
assert.deepEqual(emails(saved.junk), BOTH);
|
|
186
195
|
assert.equal(saved.correspondents.length, 0);
|
|
187
196
|
});
|
|
188
197
|
|
|
189
198
|
it("still records the envelope a message in Junk renders", async () => {
|
|
190
|
-
const saved = await save(mailboxAt("INBOX/
|
|
199
|
+
const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
|
|
191
200
|
|
|
192
201
|
assert.deepEqual(emails(saved.envelopeAddresses), BOTH);
|
|
193
202
|
});
|
|
194
203
|
|
|
195
|
-
it("withholds when Junk is one designation among several", async () => {
|
|
196
|
-
const saved = await save(
|
|
197
|
-
mailboxAt("Archive", [MailboxSpecialUse.Junk, MailboxSpecialUse.Archive]),
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
assert.equal(saved.correspondents.length, 0);
|
|
201
|
-
assert.equal(saved.junk.length, 2);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
204
|
it("keeps a message in Trash from deciding either way", async () => {
|
|
205
|
-
const saved = await save(mailboxAt("Trash",
|
|
205
|
+
const saved = await save(mailboxAt("Trash"), "Trash");
|
|
206
206
|
|
|
207
207
|
assert.deepEqual(emails(saved.neutral), BOTH);
|
|
208
208
|
assert.equal(saved.correspondents.length, 0);
|
|
@@ -210,7 +210,7 @@ describe("what a mailbox says about the addresses on its messages", () => {
|
|
|
210
210
|
});
|
|
211
211
|
|
|
212
212
|
it("re-asks every sighting of a sender a message in Junk carries", async () => {
|
|
213
|
-
const saved = await save(mailboxAt("INBOX/
|
|
213
|
+
const saved = await save(mailboxAt("INBOX/Rubbish"), "Junk");
|
|
214
214
|
|
|
215
215
|
assert.equal(saved.reconciled.length, 1);
|
|
216
216
|
assert.deepEqual(saved.reconciled, [saved.envelopeAddresses[0].messageId]);
|
|
@@ -218,14 +218,14 @@ describe("what a mailbox says about the addresses on its messages", () => {
|
|
|
218
218
|
|
|
219
219
|
it("asks nothing of a sender met on live mail", async () => {
|
|
220
220
|
const inbox = await save(mailboxAt("INBOX"));
|
|
221
|
-
const trash = await save(mailboxAt("Trash",
|
|
221
|
+
const trash = await save(mailboxAt("Trash"), "Trash");
|
|
222
222
|
|
|
223
223
|
assert.deepEqual(inbox.reconciled, []);
|
|
224
224
|
assert.deepEqual(trash.reconciled, []);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
it("harvests the same message once an ordinary folder holds it", async () => {
|
|
228
|
-
await save(mailboxAt("INBOX/
|
|
228
|
+
await save(mailboxAt("INBOX/Rubbish"), "Junk");
|
|
229
229
|
const moved = await save(mailboxAt("INBOX"));
|
|
230
230
|
|
|
231
231
|
assert.deepEqual(emails(moved.correspondents), BOTH);
|
|
@@ -30,6 +30,7 @@ import { MailboxCursorState } from "@remit/domain-enums";
|
|
|
30
30
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
31
31
|
import { MessageSyncService, selectUidsToSync } from "./message-sync.js";
|
|
32
32
|
import { QuarantineService } from "./quarantine.js";
|
|
33
|
+
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
33
34
|
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
34
35
|
|
|
35
36
|
const ACCOUNT_ID = "acc-1";
|
|
@@ -179,6 +180,7 @@ const buildHarness = (options: {
|
|
|
179
180
|
close: async () => {},
|
|
180
181
|
} as ManagedConnectionFactory,
|
|
181
182
|
mailboxService,
|
|
183
|
+
noFolderRoles,
|
|
182
184
|
{} as IMessageRepository,
|
|
183
185
|
{} as IEnvelopeRepository,
|
|
184
186
|
{} as IAddressRepository,
|
package/src/message-sync.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
IAddressRepository,
|
|
4
4
|
IEnvelopeRepository,
|
|
5
5
|
IMailboxRepository,
|
|
6
|
+
IMailboxSpecialUseRepository,
|
|
6
7
|
IMessageFlagPushRepository,
|
|
7
8
|
IMessageFlagRepository,
|
|
8
9
|
IMessageRepository,
|
|
@@ -21,11 +22,6 @@ import {
|
|
|
21
22
|
deriveThreadId,
|
|
22
23
|
isValidMessageId,
|
|
23
24
|
} from "@remit/data-ports/id";
|
|
24
|
-
import {
|
|
25
|
-
isJunkMailbox,
|
|
26
|
-
isTrashMailbox,
|
|
27
|
-
type MailboxRole,
|
|
28
|
-
} from "@remit/data-ports/mailbox-role";
|
|
29
25
|
import {
|
|
30
26
|
AddressRole,
|
|
31
27
|
MailboxCursorState,
|
|
@@ -93,9 +89,34 @@ export const isParseableEmailAddress = (
|
|
|
93
89
|
|
|
94
90
|
export type AddressSighting = "junk" | "discarded" | "correspondent";
|
|
95
91
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
92
|
+
/** The account's Junk and Trash mailboxes, resolved once per batch. */
|
|
93
|
+
export interface AccountFolderRoles {
|
|
94
|
+
readonly junkMailboxId: string | null;
|
|
95
|
+
readonly trashMailboxId: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What a sighting of an address in this mailbox is worth. Junk withholds the
|
|
100
|
+
* sender from autocomplete (#822), Trash decides nothing either way, and
|
|
101
|
+
* anywhere else is a correspondent.
|
|
102
|
+
*
|
|
103
|
+
* Decided by mailbox IDENTITY against the account's resolved roles, not by the
|
|
104
|
+
* folder's flag and name: a role belongs to exactly one folder (RFC 032), and
|
|
105
|
+
* the folder it belongs to is whichever one the user appointed. Reading the
|
|
106
|
+
* name here meant a second folder called `Spam` also withheld its senders,
|
|
107
|
+
* while the folder the user actually appointed harvested every spammer in it as
|
|
108
|
+
* a correspondent (#837).
|
|
109
|
+
*/
|
|
110
|
+
export const addressSightingIn = (
|
|
111
|
+
mailboxId: string,
|
|
112
|
+
roles: AccountFolderRoles,
|
|
113
|
+
): AddressSighting => {
|
|
114
|
+
if (roles.junkMailboxId !== null && mailboxId === roles.junkMailboxId) {
|
|
115
|
+
return "junk";
|
|
116
|
+
}
|
|
117
|
+
if (roles.trashMailboxId !== null && mailboxId === roles.trashMailboxId) {
|
|
118
|
+
return "discarded";
|
|
119
|
+
}
|
|
99
120
|
return "correspondent";
|
|
100
121
|
};
|
|
101
122
|
|
|
@@ -238,6 +259,13 @@ export class MessageSyncService {
|
|
|
238
259
|
constructor(
|
|
239
260
|
private connectionFactory: ManagedConnectionFactory,
|
|
240
261
|
private mailboxService: IMailboxRepository,
|
|
262
|
+
/**
|
|
263
|
+
* The account’s folder-role map. Required, not optional: which folder
|
|
264
|
+
* holds the Junk role decides whether a sighting withholds its sender
|
|
265
|
+
* from autocomplete, and a construction without it would quietly harvest
|
|
266
|
+
* spammers as correspondents.
|
|
267
|
+
*/
|
|
268
|
+
private mailboxSpecialUseService: IMailboxSpecialUseRepository,
|
|
241
269
|
messageService: IMessageRepository,
|
|
242
270
|
envelopeService: IEnvelopeRepository,
|
|
243
271
|
addressService: IAddressRepository,
|
|
@@ -435,9 +463,11 @@ export class MessageSyncService {
|
|
|
435
463
|
// which catches its own error and reports a `failed` outcome instead of
|
|
436
464
|
// rejecting. So one bad message can no longer abort the whole batch (the
|
|
437
465
|
// poison pill that previously froze the mailbox, #817).
|
|
466
|
+
const roles = await this.folderRolesFor(accountId);
|
|
438
467
|
const outcomes = await pMap(
|
|
439
468
|
applicable,
|
|
440
|
-
(msg) =>
|
|
469
|
+
(msg) =>
|
|
470
|
+
this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles),
|
|
441
471
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
442
472
|
);
|
|
443
473
|
|
|
@@ -668,9 +698,11 @@ export class MessageSyncService {
|
|
|
668
698
|
const newMessages =
|
|
669
699
|
newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
|
|
670
700
|
const applicable = newMessages.filter((msg) => msg.envelope !== undefined);
|
|
701
|
+
const roles = await this.folderRolesFor(accountId);
|
|
671
702
|
const outcomes = await pMap(
|
|
672
703
|
applicable,
|
|
673
|
-
(msg) =>
|
|
704
|
+
(msg) =>
|
|
705
|
+
this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles),
|
|
674
706
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
675
707
|
);
|
|
676
708
|
const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
|
|
@@ -894,9 +926,11 @@ export class MessageSyncService {
|
|
|
894
926
|
);
|
|
895
927
|
}
|
|
896
928
|
|
|
929
|
+
const roles = await this.folderRolesFor(accountId);
|
|
897
930
|
const outcomes = await pMap(
|
|
898
931
|
applicable,
|
|
899
|
-
(msg) =>
|
|
932
|
+
(msg) =>
|
|
933
|
+
this.tryApplyChange(mailbox, accountId, accountConfigId, msg, roles),
|
|
900
934
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
901
935
|
);
|
|
902
936
|
|
|
@@ -1005,9 +1039,10 @@ export class MessageSyncService {
|
|
|
1005
1039
|
accountId: string,
|
|
1006
1040
|
accountConfigId: string,
|
|
1007
1041
|
msg: ImapMessage,
|
|
1042
|
+
roles: AccountFolderRoles,
|
|
1008
1043
|
): Promise<BatchOutcome> {
|
|
1009
1044
|
const mailboxId = mailbox.mailboxId;
|
|
1010
|
-
return this.applyChange(mailbox, accountId, accountConfigId, msg)
|
|
1045
|
+
return this.applyChange(mailbox, accountId, accountConfigId, msg, roles)
|
|
1011
1046
|
.then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
|
|
1012
1047
|
.catch((error): BatchOutcome => {
|
|
1013
1048
|
this.log.warn(
|
|
@@ -1028,6 +1063,7 @@ export class MessageSyncService {
|
|
|
1028
1063
|
accountId: string,
|
|
1029
1064
|
accountConfigId: string,
|
|
1030
1065
|
msg: ImapMessage,
|
|
1066
|
+
roles: AccountFolderRoles,
|
|
1031
1067
|
): Promise<SaveMessageResult | null> {
|
|
1032
1068
|
if (!msg.envelope) return null;
|
|
1033
1069
|
|
|
@@ -1046,7 +1082,7 @@ export class MessageSyncService {
|
|
|
1046
1082
|
messageId,
|
|
1047
1083
|
);
|
|
1048
1084
|
if (!existing) {
|
|
1049
|
-
return this.saveMessage(mailbox, accountId, accountConfigId, msg);
|
|
1085
|
+
return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles);
|
|
1050
1086
|
}
|
|
1051
1087
|
|
|
1052
1088
|
await this.applyServerFlags(existing, msg.flags);
|
|
@@ -1240,14 +1276,31 @@ export class MessageSyncService {
|
|
|
1240
1276
|
* cycle. This is the guardrail that stops a single unsaveable message from
|
|
1241
1277
|
* permanently freezing the mailbox (#817).
|
|
1242
1278
|
*/
|
|
1279
|
+
/**
|
|
1280
|
+
* Which folders hold Junk and Trash for this account, resolved once per
|
|
1281
|
+
* batch rather than per message: the lookup reads an appointment row and
|
|
1282
|
+
* the account’s mailbox list, and every message in a batch shares both.
|
|
1283
|
+
*/
|
|
1284
|
+
private async folderRolesFor(accountId: string): Promise<AccountFolderRoles> {
|
|
1285
|
+
const [junk, trash] = await Promise.all([
|
|
1286
|
+
this.mailboxSpecialUseService.findJunkMailbox(accountId),
|
|
1287
|
+
this.mailboxSpecialUseService.findTrashMailbox(accountId),
|
|
1288
|
+
]);
|
|
1289
|
+
return {
|
|
1290
|
+
junkMailboxId: junk?.mailboxId ?? null,
|
|
1291
|
+
trashMailboxId: trash?.mailboxId ?? null,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1243
1295
|
private async trySaveMessage(
|
|
1244
1296
|
mailbox: MailboxItem,
|
|
1245
1297
|
accountId: string,
|
|
1246
1298
|
accountConfigId: string,
|
|
1247
1299
|
msg: ImapMessage,
|
|
1300
|
+
roles: AccountFolderRoles,
|
|
1248
1301
|
): Promise<BatchOutcome> {
|
|
1249
1302
|
const mailboxId = mailbox.mailboxId;
|
|
1250
|
-
return this.saveMessage(mailbox, accountId, accountConfigId, msg)
|
|
1303
|
+
return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles)
|
|
1251
1304
|
.then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
|
|
1252
1305
|
.catch((error): BatchOutcome => {
|
|
1253
1306
|
this.log.warn(
|
|
@@ -1268,11 +1321,12 @@ export class MessageSyncService {
|
|
|
1268
1321
|
accountId: string,
|
|
1269
1322
|
accountConfigId: string,
|
|
1270
1323
|
msg: ImapMessage,
|
|
1324
|
+
roles: AccountFolderRoles,
|
|
1271
1325
|
): Promise<SaveMessageResult | null> {
|
|
1272
1326
|
if (!msg.envelope) return null;
|
|
1273
1327
|
|
|
1274
1328
|
const mailboxId = mailbox.mailboxId;
|
|
1275
|
-
const sighting = addressSightingIn(
|
|
1329
|
+
const sighting = addressSightingIn(mailboxId, roles);
|
|
1276
1330
|
|
|
1277
1331
|
// Store envelope to preserve narrowing in closures
|
|
1278
1332
|
const envelope = msg.envelope;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A folder-role map for an account that has appointed nothing and whose server
|
|
5
|
+
* flags nothing — every mailbox is an ordinary folder. The stand-in for the
|
|
6
|
+
* many suites that construct `MessageSyncService` for a reason unrelated to
|
|
7
|
+
* which folder holds which role.
|
|
8
|
+
*/
|
|
9
|
+
export const noFolderRoles = {
|
|
10
|
+
findJunkMailbox: async () => null,
|
|
11
|
+
findTrashMailbox: async () => null,
|
|
12
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
13
|
+
|
|
14
|
+
/** A folder-role map naming the mailboxes that hold Junk and Trash. */
|
|
15
|
+
export const folderRoles = (roles: {
|
|
16
|
+
junkMailboxId?: string;
|
|
17
|
+
trashMailboxId?: string;
|
|
18
|
+
}): IMailboxSpecialUseRepository =>
|
|
19
|
+
({
|
|
20
|
+
findJunkMailbox: async () =>
|
|
21
|
+
roles.junkMailboxId
|
|
22
|
+
? { mailboxId: roles.junkMailboxId, fullPath: roles.junkMailboxId }
|
|
23
|
+
: null,
|
|
24
|
+
findTrashMailbox: async () =>
|
|
25
|
+
roles.trashMailboxId
|
|
26
|
+
? { mailboxId: roles.trashMailboxId, fullPath: roles.trashMailboxId }
|
|
27
|
+
: null,
|
|
28
|
+
}) as unknown as IMailboxSpecialUseRepository;
|
|
29
|
+
|
|
30
|
+
/** The same account, as the resolved map `saveMessage` is handed. */
|
|
31
|
+
export const NO_FOLDER_ROLES = {
|
|
32
|
+
junkMailboxId: null,
|
|
33
|
+
trashMailboxId: null,
|
|
34
|
+
};
|