@remit/mailbox-service 0.0.58 → 0.0.60
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/index.ts +1 -0
- package/src/message-delete-junk-reconcile.test.ts +110 -0
- package/src/message-delete-no-trash.test.ts +168 -0
- package/src/message-move-copy.test.ts +7 -1
- package/src/message-move-same-mailbox.test.ts +7 -0
- package/src/message-move.ts +49 -10
- package/src/message-sync-address-spoof.test.ts +9 -3
- package/src/message-sync-category.test.ts +9 -3
- package/src/message-sync-changedsince.test.ts +1 -1
- package/src/message-sync-junk-harvest.test.ts +233 -0
- package/src/message-sync-quarantine.test.ts +1 -1
- package/src/message-sync.ts +44 -13
- package/src/placement-move-unsettled.test.ts +35 -1
- package/src/placement-move.ts +6 -0
- package/src/spam-report.test.ts +2 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
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 { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
11
|
+
|
|
12
|
+
const ACCOUNT = "acc-1";
|
|
13
|
+
const ACCOUNT_CONFIG = "cfg-1";
|
|
14
|
+
const INBOX = "mbx-inbox";
|
|
15
|
+
const TRASH = "mbx-trash";
|
|
16
|
+
const MESSAGE_ID = "msg-1";
|
|
17
|
+
|
|
18
|
+
const buildWorld = (trashExists: boolean) => {
|
|
19
|
+
const reconciled: string[] = [];
|
|
20
|
+
const message = {
|
|
21
|
+
messageId: MESSAGE_ID,
|
|
22
|
+
mailboxId: INBOX,
|
|
23
|
+
uid: 337,
|
|
24
|
+
status: "active",
|
|
25
|
+
syncStatus: "synced",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const messageService = {
|
|
29
|
+
get: async () => [message],
|
|
30
|
+
update: async (_id: string, patch: Record<string, unknown>) =>
|
|
31
|
+
Object.assign(message, patch),
|
|
32
|
+
updateForMove: async (_id: string, patch: Record<string, unknown>) =>
|
|
33
|
+
Object.assign(message, patch),
|
|
34
|
+
} as unknown as IMessageRepository;
|
|
35
|
+
|
|
36
|
+
const threadMessageService = {
|
|
37
|
+
findAllByMessageId: async () => [],
|
|
38
|
+
getByMessageId: async () => ({
|
|
39
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
40
|
+
threadMessageId: "tm-1",
|
|
41
|
+
messageId: MESSAGE_ID,
|
|
42
|
+
mailboxId: INBOX,
|
|
43
|
+
}),
|
|
44
|
+
update: async () => {},
|
|
45
|
+
delete: async () => {},
|
|
46
|
+
} as unknown as IThreadMessageRepository;
|
|
47
|
+
|
|
48
|
+
const mailboxService = {
|
|
49
|
+
get: async () => [
|
|
50
|
+
{ mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
51
|
+
],
|
|
52
|
+
} as unknown as IMailboxRepository;
|
|
53
|
+
|
|
54
|
+
const mailboxSpecialUseService = {
|
|
55
|
+
findTrashMailbox: async () =>
|
|
56
|
+
trashExists ? { mailboxId: TRASH, fullPath: "Trash" } : null,
|
|
57
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
58
|
+
|
|
59
|
+
const addressService = {
|
|
60
|
+
reconcileJunkOnlyForMessage: async (messageId: string) => {
|
|
61
|
+
reconciled.push(`${messageId}@${message.mailboxId}`);
|
|
62
|
+
},
|
|
63
|
+
} as unknown as IAddressRepository;
|
|
64
|
+
|
|
65
|
+
const config: MessageMoveConfig = {
|
|
66
|
+
messageService,
|
|
67
|
+
mailboxService,
|
|
68
|
+
mailboxSpecialUseService,
|
|
69
|
+
threadMessageService,
|
|
70
|
+
addressService,
|
|
71
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const service = new MessageMoveService(config);
|
|
75
|
+
(
|
|
76
|
+
service as unknown as { enqueueEventsBatch: () => Promise<void> }
|
|
77
|
+
).enqueueEventsBatch = async () => {};
|
|
78
|
+
|
|
79
|
+
return { service, reconciled };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
describe("deleting a message re-asks what its senders stand on", () => {
|
|
83
|
+
it("re-asks when the delete moves the message to Trash", async () => {
|
|
84
|
+
const { service, reconciled } = buildWorld(true);
|
|
85
|
+
|
|
86
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(reconciled, [`${MESSAGE_ID}@${TRASH}`]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("asks nothing when the delete is permanent", async () => {
|
|
92
|
+
const { service, reconciled } = buildWorld(true);
|
|
93
|
+
|
|
94
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT, {
|
|
95
|
+
permanent: true,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.deepEqual(reconciled, []);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("asks nothing when the account has no Trash folder, because the delete is refused", async () => {
|
|
102
|
+
const { service, reconciled } = buildWorld(false);
|
|
103
|
+
|
|
104
|
+
await assert.rejects(() =>
|
|
105
|
+
service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
assert.deepEqual(reconciled, []);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -17,6 +17,11 @@ import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
|
17
17
|
import { MessageSyncService } from "./message-sync.js";
|
|
18
18
|
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
19
19
|
|
|
20
|
+
const stubAddressService = (): IAddressRepository =>
|
|
21
|
+
({
|
|
22
|
+
reconcileJunkOnlyForMessage: async () => {},
|
|
23
|
+
}) as unknown as IAddressRepository;
|
|
24
|
+
|
|
20
25
|
// A copy is a per-folder placement of the same mail. These tests pin the three
|
|
21
26
|
// properties issue #75 turns on: the copy row is deterministic (idempotent), a
|
|
22
27
|
// delete removes exactly the placement it targets, and neither the copy nor the
|
|
@@ -176,6 +181,7 @@ const buildWorld = () => {
|
|
|
176
181
|
mailboxService,
|
|
177
182
|
mailboxSpecialUseService,
|
|
178
183
|
threadMessageService,
|
|
184
|
+
addressService: stubAddressService(),
|
|
179
185
|
sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
|
|
180
186
|
};
|
|
181
187
|
|
|
@@ -288,7 +294,7 @@ const buildSyncOverDestination = (
|
|
|
288
294
|
upsertBodyParts: async () => undefined,
|
|
289
295
|
} as unknown as IEnvelopeRepository,
|
|
290
296
|
address: {
|
|
291
|
-
|
|
297
|
+
upsertCorrespondentAddress: async () => undefined,
|
|
292
298
|
upsertEnvelopeAddress: async () => undefined,
|
|
293
299
|
} as unknown as IAddressRepository,
|
|
294
300
|
threadMessage: world.threadMessageService,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type {
|
|
4
|
+
IAddressRepository,
|
|
4
5
|
IMailboxRepository,
|
|
5
6
|
IMailboxSpecialUseRepository,
|
|
6
7
|
IMessageRepository,
|
|
@@ -8,6 +9,11 @@ import type {
|
|
|
8
9
|
} from "@remit/data-ports";
|
|
9
10
|
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
10
11
|
|
|
12
|
+
const stubAddressService = (): IAddressRepository =>
|
|
13
|
+
({
|
|
14
|
+
reconcileJunkOnlyForMessage: async () => {},
|
|
15
|
+
}) as unknown as IAddressRepository;
|
|
16
|
+
|
|
11
17
|
const ACCOUNT = "acc-1";
|
|
12
18
|
const ACCOUNT_CONFIG = "cfg-1";
|
|
13
19
|
const INBOX = "mbx-inbox";
|
|
@@ -67,6 +73,7 @@ const buildWorld = () => {
|
|
|
67
73
|
mailboxService,
|
|
68
74
|
mailboxSpecialUseService,
|
|
69
75
|
threadMessageService,
|
|
76
|
+
addressService: stubAddressService(),
|
|
70
77
|
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
71
78
|
};
|
|
72
79
|
|
package/src/message-move.ts
CHANGED
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
type SQSClient,
|
|
6
6
|
} from "@aws-sdk/client-sqs";
|
|
7
7
|
import type {
|
|
8
|
+
IAddressRepository,
|
|
8
9
|
IMailboxRepository,
|
|
9
10
|
IMailboxSpecialUseRepository,
|
|
10
11
|
IMessageRepository,
|
|
11
12
|
IThreadMessageRepository,
|
|
12
13
|
} from "@remit/data-ports";
|
|
14
|
+
import { HTTPError } from "@remit/data-ports/errors";
|
|
13
15
|
import { deriveCopyMessageId } from "@remit/data-ports/id";
|
|
14
16
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
15
17
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
@@ -96,11 +98,29 @@ export interface MessageMoveConfig {
|
|
|
96
98
|
mailboxService: IMailboxRepository;
|
|
97
99
|
mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
98
100
|
threadMessageService: IThreadMessageRepository;
|
|
101
|
+
addressService: IAddressRepository;
|
|
99
102
|
sqsQueueUrl: string;
|
|
100
103
|
sqsEndpoint?: string;
|
|
101
104
|
logger?: MessageMoveLogger;
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The account resolves no Trash folder, so a move-to-Trash delete has nowhere
|
|
109
|
+
* to put the mail. A designed, user-facing outcome carrying its own text and
|
|
110
|
+
* a 409 so the backend returns it verbatim: the absence of a Trash folder is
|
|
111
|
+
* a blocker the user resolves by appointing one, never a licence to expunge.
|
|
112
|
+
*/
|
|
113
|
+
export class NoTrashMailboxError extends HTTPError {
|
|
114
|
+
name = "NoTrashMailboxError";
|
|
115
|
+
statusCode = 409;
|
|
116
|
+
|
|
117
|
+
constructor() {
|
|
118
|
+
super(
|
|
119
|
+
"This account has no Trash folder, so nothing was deleted. Appoint one under Settings → Folder roles, then delete again.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
104
124
|
/**
|
|
105
125
|
* Options for delete operations
|
|
106
126
|
*/
|
|
@@ -125,6 +145,7 @@ export class MessageMoveService {
|
|
|
125
145
|
private mailboxService: IMailboxRepository;
|
|
126
146
|
private mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
127
147
|
private threadMessageService: IThreadMessageRepository;
|
|
148
|
+
private addressService: IAddressRepository;
|
|
128
149
|
private sqs: SQSClient;
|
|
129
150
|
private queueUrl: string;
|
|
130
151
|
private log: MessageMoveLogger;
|
|
@@ -134,6 +155,7 @@ export class MessageMoveService {
|
|
|
134
155
|
this.mailboxService = config.mailboxService;
|
|
135
156
|
this.mailboxSpecialUseService = config.mailboxSpecialUseService;
|
|
136
157
|
this.threadMessageService = config.threadMessageService;
|
|
158
|
+
this.addressService = config.addressService;
|
|
137
159
|
this.queueUrl = config.sqsQueueUrl;
|
|
138
160
|
this.log = config.logger ?? noopLogger;
|
|
139
161
|
|
|
@@ -187,9 +209,26 @@ export class MessageMoveService {
|
|
|
187
209
|
);
|
|
188
210
|
const mailboxMap = new Map(mailboxes.map((m) => [m.mailboxId, m]));
|
|
189
211
|
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
|
193
232
|
|
|
194
233
|
// Group messages by operation type
|
|
195
234
|
const moveToTrashMessages: Array<{
|
|
@@ -208,12 +247,8 @@ export class MessageMoveService {
|
|
|
208
247
|
if (!sourceMailbox) continue;
|
|
209
248
|
|
|
210
249
|
const isInTrash =
|
|
211
|
-
trashMailbox && message.mailboxId === trashMailbox.mailboxId;
|
|
212
|
-
const shouldMoveToTrash =
|
|
213
|
-
options.toTrash !== false &&
|
|
214
|
-
!options.permanent &&
|
|
215
|
-
!isInTrash &&
|
|
216
|
-
trashMailbox;
|
|
250
|
+
trashMailbox !== null && message.mailboxId === trashMailbox.mailboxId;
|
|
251
|
+
const shouldMoveToTrash = trashMailbox !== null && !isInTrash;
|
|
217
252
|
|
|
218
253
|
const entry = {
|
|
219
254
|
messageId: message.messageId,
|
|
@@ -224,7 +259,7 @@ export class MessageMoveService {
|
|
|
224
259
|
},
|
|
225
260
|
};
|
|
226
261
|
|
|
227
|
-
if (shouldMoveToTrash
|
|
262
|
+
if (shouldMoveToTrash) {
|
|
228
263
|
moveToTrashMessages.push(entry);
|
|
229
264
|
} else {
|
|
230
265
|
permanentDeleteMessages.push(entry);
|
|
@@ -254,6 +289,8 @@ export class MessageMoveService {
|
|
|
254
289
|
true,
|
|
255
290
|
);
|
|
256
291
|
|
|
292
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId);
|
|
293
|
+
|
|
257
294
|
events.push({
|
|
258
295
|
type: "MESSAGE_DELETE",
|
|
259
296
|
eventId: randomUUID(),
|
|
@@ -385,6 +422,8 @@ export class MessageMoveService {
|
|
|
385
422
|
isMovingToTrash,
|
|
386
423
|
);
|
|
387
424
|
|
|
425
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId);
|
|
426
|
+
|
|
388
427
|
// If moving FROM Trash, clear isDeleted
|
|
389
428
|
if (isMovingFromTrash) {
|
|
390
429
|
await this.updateThreadMessageDeleted(accountConfigId, messageId, false);
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
IMailboxRepository,
|
|
10
10
|
IMessageRepository,
|
|
11
11
|
IThreadMessageRepository,
|
|
12
|
+
MailboxItem,
|
|
12
13
|
MessageItem,
|
|
13
14
|
ThreadMessageItem,
|
|
14
15
|
} from "@remit/data-ports";
|
|
@@ -64,7 +65,7 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
|
|
|
64
65
|
} as unknown as IEnvelopeRepository;
|
|
65
66
|
|
|
66
67
|
const addressService = {
|
|
67
|
-
|
|
68
|
+
upsertCorrespondentAddress: async (input: CreateAddressInput) => {
|
|
68
69
|
saved.addresses.push(input);
|
|
69
70
|
},
|
|
70
71
|
upsertEnvelopeAddress: async (input: CreateEnvelopeAddressInput) => {
|
|
@@ -93,13 +94,18 @@ const harvest = async (envelope: Partial<ImapEnvelope>): Promise<Saved> => {
|
|
|
93
94
|
await (
|
|
94
95
|
service as unknown as {
|
|
95
96
|
saveMessage: (
|
|
96
|
-
|
|
97
|
+
mailbox: MailboxItem,
|
|
97
98
|
accountId: string,
|
|
98
99
|
accountConfigId: string,
|
|
99
100
|
msg: ImapMessage,
|
|
100
101
|
) => Promise<unknown>;
|
|
101
102
|
}
|
|
102
|
-
).saveMessage(
|
|
103
|
+
).saveMessage(
|
|
104
|
+
{ mailboxId: "mbx-1", fullPath: "INBOX" } as MailboxItem,
|
|
105
|
+
"acct-1",
|
|
106
|
+
"cfg-1",
|
|
107
|
+
msg,
|
|
108
|
+
);
|
|
103
109
|
|
|
104
110
|
return saved;
|
|
105
111
|
};
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
IMailboxRepository,
|
|
26
26
|
IMessageRepository,
|
|
27
27
|
IThreadMessageRepository,
|
|
28
|
+
MailboxItem,
|
|
28
29
|
MessageItem,
|
|
29
30
|
ThreadMessageItem,
|
|
30
31
|
} from "@remit/data-ports";
|
|
@@ -89,7 +90,7 @@ const saveIntoMailbox = async (
|
|
|
89
90
|
} as unknown as IEnvelopeRepository;
|
|
90
91
|
|
|
91
92
|
const addressService = {
|
|
92
|
-
|
|
93
|
+
upsertCorrespondentAddress: async () => {},
|
|
93
94
|
upsertEnvelopeAddress: async () => {},
|
|
94
95
|
} as unknown as IAddressRepository;
|
|
95
96
|
|
|
@@ -105,13 +106,18 @@ const saveIntoMailbox = async (
|
|
|
105
106
|
await (
|
|
106
107
|
service as unknown as {
|
|
107
108
|
saveMessage: (
|
|
108
|
-
|
|
109
|
+
mailbox: MailboxItem,
|
|
109
110
|
accountId: string,
|
|
110
111
|
accountConfigId: string,
|
|
111
112
|
msg: ImapMessage,
|
|
112
113
|
) => Promise<unknown>;
|
|
113
114
|
}
|
|
114
|
-
).saveMessage(
|
|
115
|
+
).saveMessage(
|
|
116
|
+
{ mailboxId: "mbx-1", fullPath: "INBOX" } as MailboxItem,
|
|
117
|
+
"acct-1",
|
|
118
|
+
"cfg-1",
|
|
119
|
+
imapMessage,
|
|
120
|
+
);
|
|
115
121
|
|
|
116
122
|
assert.equal(inputs.length, 1);
|
|
117
123
|
const [input] = inputs;
|
|
@@ -219,7 +219,7 @@ const buildHarness = (options: HarnessOptions): Harness => {
|
|
|
219
219
|
upsertBodyParts: async () => undefined,
|
|
220
220
|
} as unknown as IEnvelopeRepository,
|
|
221
221
|
address: {
|
|
222
|
-
|
|
222
|
+
upsertCorrespondentAddress: async () => undefined,
|
|
223
223
|
upsertEnvelopeAddress: async () => undefined,
|
|
224
224
|
} as unknown as IAddressRepository,
|
|
225
225
|
threadMessage: threadMessageService,
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
CreateAddressInput,
|
|
5
|
+
CreateEnvelopeAddressInput,
|
|
6
|
+
CreateThreadMessageInput,
|
|
7
|
+
IAddressRepository,
|
|
8
|
+
IEnvelopeRepository,
|
|
9
|
+
IMailboxRepository,
|
|
10
|
+
IMessageRepository,
|
|
11
|
+
IThreadMessageRepository,
|
|
12
|
+
MailboxItem,
|
|
13
|
+
MessageItem,
|
|
14
|
+
ThreadMessageItem,
|
|
15
|
+
} from "@remit/data-ports";
|
|
16
|
+
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
17
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
18
|
+
import { addressSightingIn, MessageSyncService } from "./message-sync.js";
|
|
19
|
+
import type { ImapEnvelope, ImapMessage } from "./types.js";
|
|
20
|
+
|
|
21
|
+
const stub = <T>(): T => ({}) as T;
|
|
22
|
+
|
|
23
|
+
const envelope: ImapEnvelope = {
|
|
24
|
+
date: new Date(0).toISOString(),
|
|
25
|
+
messageId: "<root@example.com>",
|
|
26
|
+
subject: "Subject",
|
|
27
|
+
from: [{ name: "Pharma Deals", mailbox: "sales", host: "pharma.example" }],
|
|
28
|
+
sender: [],
|
|
29
|
+
replyTo: [],
|
|
30
|
+
to: [{ name: "", mailbox: "victim", host: "ischen.nl" }],
|
|
31
|
+
cc: [],
|
|
32
|
+
bcc: [],
|
|
33
|
+
inReplyTo: "",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
interface Saved {
|
|
37
|
+
correspondents: CreateAddressInput[];
|
|
38
|
+
junk: CreateAddressInput[];
|
|
39
|
+
neutral: CreateAddressInput[];
|
|
40
|
+
envelopeAddresses: CreateEnvelopeAddressInput[];
|
|
41
|
+
reconciled: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mailboxAt = (
|
|
45
|
+
fullPath: string,
|
|
46
|
+
specialUse?: MailboxItem["specialUse"],
|
|
47
|
+
): MailboxItem =>
|
|
48
|
+
({
|
|
49
|
+
fullPath,
|
|
50
|
+
hierarchyDelimiter: "/",
|
|
51
|
+
specialUse,
|
|
52
|
+
}) as MailboxItem;
|
|
53
|
+
|
|
54
|
+
const save = async (mailbox: MailboxItem): Promise<Saved> => {
|
|
55
|
+
const saved: Saved = {
|
|
56
|
+
correspondents: [],
|
|
57
|
+
junk: [],
|
|
58
|
+
neutral: [],
|
|
59
|
+
envelopeAddresses: [],
|
|
60
|
+
reconciled: [],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const messageService = {
|
|
64
|
+
upsertWithStatus: async (input: unknown) => ({
|
|
65
|
+
item: input as MessageItem,
|
|
66
|
+
created: true,
|
|
67
|
+
}),
|
|
68
|
+
} as unknown as IMessageRepository;
|
|
69
|
+
|
|
70
|
+
const threadMessageService = {
|
|
71
|
+
create: async (input: CreateThreadMessageInput) =>
|
|
72
|
+
input as unknown as ThreadMessageItem,
|
|
73
|
+
} as unknown as IThreadMessageRepository;
|
|
74
|
+
|
|
75
|
+
const envelopeService = {
|
|
76
|
+
upsertEnvelope: async () => {},
|
|
77
|
+
upsertBodyParts: async () => {},
|
|
78
|
+
} as unknown as IEnvelopeRepository;
|
|
79
|
+
|
|
80
|
+
const addressService = {
|
|
81
|
+
upsertCorrespondentAddress: async (input: CreateAddressInput) => {
|
|
82
|
+
saved.correspondents.push(input);
|
|
83
|
+
},
|
|
84
|
+
upsertJunkAddress: async (input: CreateAddressInput) => {
|
|
85
|
+
saved.junk.push(input);
|
|
86
|
+
},
|
|
87
|
+
upsertAddress: async (input: CreateAddressInput) => {
|
|
88
|
+
saved.neutral.push(input);
|
|
89
|
+
},
|
|
90
|
+
upsertEnvelopeAddress: async (input: CreateEnvelopeAddressInput) => {
|
|
91
|
+
saved.envelopeAddresses.push(input);
|
|
92
|
+
},
|
|
93
|
+
reconcileJunkOnlyForMessage: async (messageId: string) => {
|
|
94
|
+
saved.reconciled.push(messageId);
|
|
95
|
+
},
|
|
96
|
+
} as unknown as IAddressRepository;
|
|
97
|
+
|
|
98
|
+
const service = new MessageSyncService(
|
|
99
|
+
stub<ManagedConnectionFactory>(),
|
|
100
|
+
stub<IMailboxRepository>(),
|
|
101
|
+
messageService,
|
|
102
|
+
envelopeService,
|
|
103
|
+
addressService,
|
|
104
|
+
threadMessageService,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const msg = {
|
|
108
|
+
uid: 42,
|
|
109
|
+
seq: 1,
|
|
110
|
+
size: 100,
|
|
111
|
+
internalDate: new Date(0),
|
|
112
|
+
flags: [],
|
|
113
|
+
envelope,
|
|
114
|
+
} as unknown as ImapMessage;
|
|
115
|
+
|
|
116
|
+
await (
|
|
117
|
+
service as unknown as {
|
|
118
|
+
saveMessage: (
|
|
119
|
+
mailbox: MailboxItem,
|
|
120
|
+
accountId: string,
|
|
121
|
+
accountConfigId: string,
|
|
122
|
+
msg: ImapMessage,
|
|
123
|
+
) => Promise<unknown>;
|
|
124
|
+
}
|
|
125
|
+
).saveMessage(mailbox, "acct-1", "cfg-1", msg);
|
|
126
|
+
|
|
127
|
+
return saved;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const emails = (inputs: Array<{ normalizedEmail: string }>): string[] =>
|
|
131
|
+
inputs.map((input) => input.normalizedEmail);
|
|
132
|
+
|
|
133
|
+
const BOTH = ["sales@pharma.example", "victim@ischen.nl"];
|
|
134
|
+
|
|
135
|
+
describe("what a mailbox says about the addresses on its messages", () => {
|
|
136
|
+
const at = (fullPath: string, specialUse?: string[]) => ({
|
|
137
|
+
fullPath,
|
|
138
|
+
hierarchyDelimiter: "/",
|
|
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");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("falls back to the folder name on a server without SPECIAL-USE", () => {
|
|
151
|
+
assert.equal(addressSightingIn(at("Spam")), "junk");
|
|
152
|
+
assert.equal(addressSightingIn(at("[Gmail]/Spam")), "junk");
|
|
153
|
+
assert.equal(addressSightingIn(at("Deleted Items")), "discarded");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("reads a Junk folder nested under any prefix", () => {
|
|
157
|
+
assert.equal(addressSightingIn(at("INBOX/Spam")), "junk");
|
|
158
|
+
assert.equal(addressSightingIn(at("INBOX/Junk E-mail")), "junk");
|
|
159
|
+
assert.equal(
|
|
160
|
+
addressSightingIn({ fullPath: "Mail.Junk", hierarchyDelimiter: "." }),
|
|
161
|
+
"junk",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("never reads a prefix as the folder it names", () => {
|
|
166
|
+
assert.equal(addressSightingIn(at("Spam/Receipts")), "correspondent");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("answers for a mailbox carrying no delimiter", () => {
|
|
170
|
+
assert.equal(addressSightingIn({ fullPath: "Spam" }), "junk");
|
|
171
|
+
assert.equal(addressSightingIn({ fullPath: "INBOX" }), "correspondent");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("harvests every envelope address of an ordinary message", async () => {
|
|
175
|
+
const saved = await save(mailboxAt("INBOX"));
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(emails(saved.correspondents), BOTH);
|
|
178
|
+
assert.equal(saved.junk.length, 0);
|
|
179
|
+
assert.equal(saved.neutral.length, 0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("withholds every envelope address of a message in Junk", async () => {
|
|
183
|
+
const saved = await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
|
|
184
|
+
|
|
185
|
+
assert.deepEqual(emails(saved.junk), BOTH);
|
|
186
|
+
assert.equal(saved.correspondents.length, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("still records the envelope a message in Junk renders", async () => {
|
|
190
|
+
const saved = await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
|
|
191
|
+
|
|
192
|
+
assert.deepEqual(emails(saved.envelopeAddresses), BOTH);
|
|
193
|
+
});
|
|
194
|
+
|
|
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
|
+
it("keeps a message in Trash from deciding either way", async () => {
|
|
205
|
+
const saved = await save(mailboxAt("Trash", [MailboxSpecialUse.Trash]));
|
|
206
|
+
|
|
207
|
+
assert.deepEqual(emails(saved.neutral), BOTH);
|
|
208
|
+
assert.equal(saved.correspondents.length, 0);
|
|
209
|
+
assert.equal(saved.junk.length, 0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("re-asks every sighting of a sender a message in Junk carries", async () => {
|
|
213
|
+
const saved = await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
|
|
214
|
+
|
|
215
|
+
assert.equal(saved.reconciled.length, 1);
|
|
216
|
+
assert.deepEqual(saved.reconciled, [saved.envelopeAddresses[0].messageId]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("asks nothing of a sender met on live mail", async () => {
|
|
220
|
+
const inbox = await save(mailboxAt("INBOX"));
|
|
221
|
+
const trash = await save(mailboxAt("Trash", [MailboxSpecialUse.Trash]));
|
|
222
|
+
|
|
223
|
+
assert.deepEqual(inbox.reconciled, []);
|
|
224
|
+
assert.deepEqual(trash.reconciled, []);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("harvests the same message once an ordinary folder holds it", async () => {
|
|
228
|
+
await save(mailboxAt("INBOX/Spam", [MailboxSpecialUse.Junk]));
|
|
229
|
+
const moved = await save(mailboxAt("INBOX"));
|
|
230
|
+
|
|
231
|
+
assert.deepEqual(emails(moved.correspondents), BOTH);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -159,7 +159,7 @@ const buildHarness = (options: {
|
|
|
159
159
|
upsertBodyParts: async () => undefined,
|
|
160
160
|
} as unknown as IEnvelopeRepository,
|
|
161
161
|
address: {
|
|
162
|
-
|
|
162
|
+
upsertCorrespondentAddress: async () => undefined,
|
|
163
163
|
upsertEnvelopeAddress: async () => undefined,
|
|
164
164
|
} as unknown as IAddressRepository,
|
|
165
165
|
threadMessage: threadMessageService,
|
package/src/message-sync.ts
CHANGED
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
deriveThreadId,
|
|
22
22
|
isValidMessageId,
|
|
23
23
|
} from "@remit/data-ports/id";
|
|
24
|
+
import {
|
|
25
|
+
isJunkMailbox,
|
|
26
|
+
isTrashMailbox,
|
|
27
|
+
type MailboxRole,
|
|
28
|
+
} from "@remit/data-ports/mailbox-role";
|
|
24
29
|
import {
|
|
25
30
|
AddressRole,
|
|
26
31
|
MailboxCursorState,
|
|
@@ -86,6 +91,14 @@ export const isParseableEmailAddress = (
|
|
|
86
91
|
return host.includes(".");
|
|
87
92
|
};
|
|
88
93
|
|
|
94
|
+
export type AddressSighting = "junk" | "discarded" | "correspondent";
|
|
95
|
+
|
|
96
|
+
export const addressSightingIn = (mailbox: MailboxRole): AddressSighting => {
|
|
97
|
+
if (isJunkMailbox(mailbox)) return "junk";
|
|
98
|
+
if (isTrashMailbox(mailbox)) return "discarded";
|
|
99
|
+
return "correspondent";
|
|
100
|
+
};
|
|
101
|
+
|
|
89
102
|
/**
|
|
90
103
|
* The name an envelope carries for an address, as it should be stored: any
|
|
91
104
|
* claim to be some other address removed (issue #826), the rest of the name
|
|
@@ -424,7 +437,7 @@ export class MessageSyncService {
|
|
|
424
437
|
// poison pill that previously froze the mailbox, #817).
|
|
425
438
|
const outcomes = await pMap(
|
|
426
439
|
applicable,
|
|
427
|
-
(msg) => this.trySaveMessage(
|
|
440
|
+
(msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg),
|
|
428
441
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
429
442
|
);
|
|
430
443
|
|
|
@@ -657,7 +670,7 @@ export class MessageSyncService {
|
|
|
657
670
|
const applicable = newMessages.filter((msg) => msg.envelope !== undefined);
|
|
658
671
|
const outcomes = await pMap(
|
|
659
672
|
applicable,
|
|
660
|
-
(msg) => this.trySaveMessage(
|
|
673
|
+
(msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg),
|
|
661
674
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
662
675
|
);
|
|
663
676
|
const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
|
|
@@ -883,7 +896,7 @@ export class MessageSyncService {
|
|
|
883
896
|
|
|
884
897
|
const outcomes = await pMap(
|
|
885
898
|
applicable,
|
|
886
|
-
(msg) => this.tryApplyChange(
|
|
899
|
+
(msg) => this.tryApplyChange(mailbox, accountId, accountConfigId, msg),
|
|
887
900
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
888
901
|
);
|
|
889
902
|
|
|
@@ -988,12 +1001,13 @@ export class MessageSyncService {
|
|
|
988
1001
|
* holds the watermark back instead of failing the round.
|
|
989
1002
|
*/
|
|
990
1003
|
private async tryApplyChange(
|
|
991
|
-
|
|
1004
|
+
mailbox: MailboxItem,
|
|
992
1005
|
accountId: string,
|
|
993
1006
|
accountConfigId: string,
|
|
994
1007
|
msg: ImapMessage,
|
|
995
1008
|
): Promise<BatchOutcome> {
|
|
996
|
-
|
|
1009
|
+
const mailboxId = mailbox.mailboxId;
|
|
1010
|
+
return this.applyChange(mailbox, accountId, accountConfigId, msg)
|
|
997
1011
|
.then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
|
|
998
1012
|
.catch((error): BatchOutcome => {
|
|
999
1013
|
this.log.warn(
|
|
@@ -1010,7 +1024,7 @@ export class MessageSyncService {
|
|
|
1010
1024
|
}
|
|
1011
1025
|
|
|
1012
1026
|
private async applyChange(
|
|
1013
|
-
|
|
1027
|
+
mailbox: MailboxItem,
|
|
1014
1028
|
accountId: string,
|
|
1015
1029
|
accountConfigId: string,
|
|
1016
1030
|
msg: ImapMessage,
|
|
@@ -1020,7 +1034,7 @@ export class MessageSyncService {
|
|
|
1020
1034
|
const messageId = deriveMessageIdFromSource(accountId, {
|
|
1021
1035
|
messageId: msg.envelope.messageId,
|
|
1022
1036
|
uid: msg.uid,
|
|
1023
|
-
mailboxId,
|
|
1037
|
+
mailboxId: mailbox.mailboxId,
|
|
1024
1038
|
date: msg.envelope.date,
|
|
1025
1039
|
subject: msg.envelope.subject,
|
|
1026
1040
|
fromMailbox: msg.envelope.from?.[0]?.mailbox,
|
|
@@ -1032,7 +1046,7 @@ export class MessageSyncService {
|
|
|
1032
1046
|
messageId,
|
|
1033
1047
|
);
|
|
1034
1048
|
if (!existing) {
|
|
1035
|
-
return this.saveMessage(
|
|
1049
|
+
return this.saveMessage(mailbox, accountId, accountConfigId, msg);
|
|
1036
1050
|
}
|
|
1037
1051
|
|
|
1038
1052
|
await this.applyServerFlags(existing, msg.flags);
|
|
@@ -1227,12 +1241,13 @@ export class MessageSyncService {
|
|
|
1227
1241
|
* permanently freezing the mailbox (#817).
|
|
1228
1242
|
*/
|
|
1229
1243
|
private async trySaveMessage(
|
|
1230
|
-
|
|
1244
|
+
mailbox: MailboxItem,
|
|
1231
1245
|
accountId: string,
|
|
1232
1246
|
accountConfigId: string,
|
|
1233
1247
|
msg: ImapMessage,
|
|
1234
1248
|
): Promise<BatchOutcome> {
|
|
1235
|
-
|
|
1249
|
+
const mailboxId = mailbox.mailboxId;
|
|
1250
|
+
return this.saveMessage(mailbox, accountId, accountConfigId, msg)
|
|
1236
1251
|
.then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
|
|
1237
1252
|
.catch((error): BatchOutcome => {
|
|
1238
1253
|
this.log.warn(
|
|
@@ -1249,13 +1264,16 @@ export class MessageSyncService {
|
|
|
1249
1264
|
}
|
|
1250
1265
|
|
|
1251
1266
|
private async saveMessage(
|
|
1252
|
-
|
|
1267
|
+
mailbox: MailboxItem,
|
|
1253
1268
|
accountId: string,
|
|
1254
1269
|
accountConfigId: string,
|
|
1255
1270
|
msg: ImapMessage,
|
|
1256
1271
|
): Promise<SaveMessageResult | null> {
|
|
1257
1272
|
if (!msg.envelope) return null;
|
|
1258
1273
|
|
|
1274
|
+
const mailboxId = mailbox.mailboxId;
|
|
1275
|
+
const sighting = addressSightingIn(mailbox);
|
|
1276
|
+
|
|
1259
1277
|
// Store envelope to preserve narrowing in closures
|
|
1260
1278
|
const envelope = msg.envelope;
|
|
1261
1279
|
|
|
@@ -1338,6 +1356,7 @@ export class MessageSyncService {
|
|
|
1338
1356
|
accountConfigId,
|
|
1339
1357
|
addresses,
|
|
1340
1358
|
role,
|
|
1359
|
+
sighting,
|
|
1341
1360
|
);
|
|
1342
1361
|
}
|
|
1343
1362
|
|
|
@@ -1359,6 +1378,10 @@ export class MessageSyncService {
|
|
|
1359
1378
|
});
|
|
1360
1379
|
owned = created || item.mailboxId === mailboxId;
|
|
1361
1380
|
|
|
1381
|
+
if (sighting === "junk") {
|
|
1382
|
+
await repos.address.reconcileJunkOnlyForMessage(messageId);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1362
1385
|
await this.createThreadForMessage(
|
|
1363
1386
|
repos.threadMessage,
|
|
1364
1387
|
messageId,
|
|
@@ -1393,6 +1416,7 @@ export class MessageSyncService {
|
|
|
1393
1416
|
accountConfigId: string,
|
|
1394
1417
|
addresses: ImapAddress[] | undefined,
|
|
1395
1418
|
role: (typeof AddressRole)[keyof typeof AddressRole],
|
|
1419
|
+
sighting: AddressSighting,
|
|
1396
1420
|
) {
|
|
1397
1421
|
if (!addresses) return;
|
|
1398
1422
|
|
|
@@ -1430,7 +1454,7 @@ export class MessageSyncService {
|
|
|
1430
1454
|
|
|
1431
1455
|
const envelopeAddressId = deriveEnvelopeAddressId(messageId, role, order);
|
|
1432
1456
|
|
|
1433
|
-
|
|
1457
|
+
const addressInput = {
|
|
1434
1458
|
addressId,
|
|
1435
1459
|
accountConfigId,
|
|
1436
1460
|
localPart,
|
|
@@ -1438,7 +1462,14 @@ export class MessageSyncService {
|
|
|
1438
1462
|
normalizedEmail,
|
|
1439
1463
|
normalizedCompound,
|
|
1440
1464
|
displayName,
|
|
1441
|
-
}
|
|
1465
|
+
};
|
|
1466
|
+
if (sighting === "junk") {
|
|
1467
|
+
await addressService.upsertJunkAddress(addressInput);
|
|
1468
|
+
} else if (sighting === "discarded") {
|
|
1469
|
+
await addressService.upsertAddress(addressInput);
|
|
1470
|
+
} else {
|
|
1471
|
+
await addressService.upsertCorrespondentAddress(addressInput);
|
|
1472
|
+
}
|
|
1442
1473
|
|
|
1443
1474
|
await addressService.upsertEnvelopeAddress({
|
|
1444
1475
|
envelopeAddressId,
|
|
@@ -10,6 +10,7 @@ import assert from "node:assert/strict";
|
|
|
10
10
|
import { afterEach, beforeEach, describe, it, mock } from "node:test";
|
|
11
11
|
import { SQSClient } from "@aws-sdk/client-sqs";
|
|
12
12
|
import type {
|
|
13
|
+
IAddressRepository,
|
|
13
14
|
IMessagePlacementMoveRepository,
|
|
14
15
|
IMessageRepository,
|
|
15
16
|
IThreadMessageRepository,
|
|
@@ -33,6 +34,7 @@ interface Harness {
|
|
|
33
34
|
row: MessageItem;
|
|
34
35
|
puts: PutMessagePlacementMoveInput[];
|
|
35
36
|
marker: MessagePlacementMoveItem | null;
|
|
37
|
+
reconciled: string[];
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const buildHarness = (): Harness => {
|
|
@@ -46,7 +48,14 @@ const buildHarness = (): Harness => {
|
|
|
46
48
|
} as unknown as MessageItem;
|
|
47
49
|
|
|
48
50
|
const puts: PutMessagePlacementMoveInput[] = [];
|
|
49
|
-
const
|
|
51
|
+
const reconciled: string[] = [];
|
|
52
|
+
const harness = { row, puts, marker: null, reconciled } as Harness;
|
|
53
|
+
|
|
54
|
+
const addressService = {
|
|
55
|
+
reconcileJunkOnlyForMessage: async (messageId: string) => {
|
|
56
|
+
reconciled.push(`${messageId}@${row.mailboxId}`);
|
|
57
|
+
},
|
|
58
|
+
} as unknown as IAddressRepository;
|
|
50
59
|
|
|
51
60
|
const messageService = {
|
|
52
61
|
get: async () => row,
|
|
@@ -101,6 +110,7 @@ const buildHarness = (): Harness => {
|
|
|
101
110
|
messageService,
|
|
102
111
|
threadMessageService,
|
|
103
112
|
markerService,
|
|
113
|
+
addressService,
|
|
104
114
|
sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/000/message-mgmt",
|
|
105
115
|
moveSettleTimeoutMs: 200,
|
|
106
116
|
moveSettlePollMs: 10,
|
|
@@ -222,4 +232,28 @@ describe("PlacementMoveService — a second destination while the first move is
|
|
|
222
232
|
assert.equal(harness.puts[1]?.sourceMailboxId, ARCHIVE_ID);
|
|
223
233
|
assert.equal(harness.puts[1]?.destinationMailboxId, JUNK_ID);
|
|
224
234
|
});
|
|
235
|
+
|
|
236
|
+
it("marks the sender when the classifier demotes the message", async () => {
|
|
237
|
+
await harness.service.moveMessage(
|
|
238
|
+
ACCOUNT_CONFIG_ID,
|
|
239
|
+
MESSAGE_ID,
|
|
240
|
+
JUNK_ID,
|
|
241
|
+
ACCOUNT_ID,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
assert.deepEqual(harness.reconciled, [`${MESSAGE_ID}@${JUNK_ID}`]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("asks nothing of a destination the message is already in", async () => {
|
|
248
|
+
Object.assign(harness.row, { mailboxId: JUNK_ID });
|
|
249
|
+
|
|
250
|
+
await harness.service.moveMessage(
|
|
251
|
+
ACCOUNT_CONFIG_ID,
|
|
252
|
+
MESSAGE_ID,
|
|
253
|
+
JUNK_ID,
|
|
254
|
+
ACCOUNT_ID,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
assert.deepEqual(harness.reconciled, []);
|
|
258
|
+
});
|
|
225
259
|
});
|
package/src/placement-move.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
3
3
|
import type {
|
|
4
|
+
IAddressRepository,
|
|
4
5
|
IMessagePlacementMoveRepository,
|
|
5
6
|
IMessageRepository,
|
|
6
7
|
IThreadMessageRepository,
|
|
@@ -42,6 +43,7 @@ export interface PlacementMoveConfig {
|
|
|
42
43
|
messageService: IMessageRepository;
|
|
43
44
|
threadMessageService: IThreadMessageRepository;
|
|
44
45
|
markerService: IMessagePlacementMoveRepository;
|
|
46
|
+
addressService: IAddressRepository;
|
|
45
47
|
sqsQueueUrl: string;
|
|
46
48
|
sqsEndpoint?: string;
|
|
47
49
|
logger?: PlacementMoveLogger;
|
|
@@ -84,6 +86,7 @@ export class PlacementMoveService {
|
|
|
84
86
|
private messageService: IMessageRepository;
|
|
85
87
|
private threadMessageService: IThreadMessageRepository;
|
|
86
88
|
private markerService: IMessagePlacementMoveRepository;
|
|
89
|
+
private addressService: IAddressRepository;
|
|
87
90
|
private sqs: SQSClient;
|
|
88
91
|
private queueUrl: string;
|
|
89
92
|
private log: PlacementMoveLogger;
|
|
@@ -94,6 +97,7 @@ export class PlacementMoveService {
|
|
|
94
97
|
this.messageService = config.messageService;
|
|
95
98
|
this.threadMessageService = config.threadMessageService;
|
|
96
99
|
this.markerService = config.markerService;
|
|
100
|
+
this.addressService = config.addressService;
|
|
97
101
|
this.queueUrl = config.sqsQueueUrl;
|
|
98
102
|
this.log = config.logger ?? noopLogger;
|
|
99
103
|
this.moveSettleTimeoutMs =
|
|
@@ -196,6 +200,8 @@ export class PlacementMoveService {
|
|
|
196
200
|
originalUid: message.uid,
|
|
197
201
|
});
|
|
198
202
|
|
|
203
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId);
|
|
204
|
+
|
|
199
205
|
// The queue kick is a serious operational step, not a routine one — a
|
|
200
206
|
// failure here MUST propagate (never swallowed): the marker stays
|
|
201
207
|
// `pending`, the surrounding body-sync call fails, SQS redelivers, and
|
package/src/spam-report.test.ts
CHANGED
|
@@ -204,6 +204,7 @@ const buildWorld = (
|
|
|
204
204
|
addresses.set(input.addressId, { flags: {} });
|
|
205
205
|
return { ...input, flags: {} };
|
|
206
206
|
},
|
|
207
|
+
reconcileJunkOnlyForMessage: async () => {},
|
|
207
208
|
mergeFlags: async (
|
|
208
209
|
_accountConfigId: string,
|
|
209
210
|
addressId: string,
|
|
@@ -273,6 +274,7 @@ const buildWorld = (
|
|
|
273
274
|
mailboxService,
|
|
274
275
|
mailboxSpecialUseService,
|
|
275
276
|
threadMessageService,
|
|
277
|
+
addressService,
|
|
276
278
|
sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
|
|
277
279
|
});
|
|
278
280
|
(
|