@remit/mailbox-service 0.0.57 → 0.0.59
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/message-delete-junk-reconcile.test.ts +108 -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 +8 -0
- 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/outbox-queue.test.ts +157 -0
- package/src/outbox-queue.ts +32 -1
- 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
|
@@ -0,0 +1,108 @@
|
|
|
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", async () => {
|
|
102
|
+
const { service, reconciled } = buildWorld(false);
|
|
103
|
+
|
|
104
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(reconciled, []);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -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,6 +5,7 @@ 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,
|
|
@@ -96,6 +97,7 @@ export interface MessageMoveConfig {
|
|
|
96
97
|
mailboxService: IMailboxRepository;
|
|
97
98
|
mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
98
99
|
threadMessageService: IThreadMessageRepository;
|
|
100
|
+
addressService: IAddressRepository;
|
|
99
101
|
sqsQueueUrl: string;
|
|
100
102
|
sqsEndpoint?: string;
|
|
101
103
|
logger?: MessageMoveLogger;
|
|
@@ -125,6 +127,7 @@ export class MessageMoveService {
|
|
|
125
127
|
private mailboxService: IMailboxRepository;
|
|
126
128
|
private mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
127
129
|
private threadMessageService: IThreadMessageRepository;
|
|
130
|
+
private addressService: IAddressRepository;
|
|
128
131
|
private sqs: SQSClient;
|
|
129
132
|
private queueUrl: string;
|
|
130
133
|
private log: MessageMoveLogger;
|
|
@@ -134,6 +137,7 @@ export class MessageMoveService {
|
|
|
134
137
|
this.mailboxService = config.mailboxService;
|
|
135
138
|
this.mailboxSpecialUseService = config.mailboxSpecialUseService;
|
|
136
139
|
this.threadMessageService = config.threadMessageService;
|
|
140
|
+
this.addressService = config.addressService;
|
|
137
141
|
this.queueUrl = config.sqsQueueUrl;
|
|
138
142
|
this.log = config.logger ?? noopLogger;
|
|
139
143
|
|
|
@@ -254,6 +258,8 @@ export class MessageMoveService {
|
|
|
254
258
|
true,
|
|
255
259
|
);
|
|
256
260
|
|
|
261
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId);
|
|
262
|
+
|
|
257
263
|
events.push({
|
|
258
264
|
type: "MESSAGE_DELETE",
|
|
259
265
|
eventId: randomUUID(),
|
|
@@ -385,6 +391,8 @@ export class MessageMoveService {
|
|
|
385
391
|
isMovingToTrash,
|
|
386
392
|
);
|
|
387
393
|
|
|
394
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId);
|
|
395
|
+
|
|
388
396
|
// If moving FROM Trash, clear isDeleted
|
|
389
397
|
if (isMovingFromTrash) {
|
|
390
398
|
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,
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A message with nobody to send to must be refused where the person who pressed
|
|
3
|
+
* Send can see the refusal.
|
|
4
|
+
*
|
|
5
|
+
* `@minItems(1)` on `CreateOutboxMessageInput.toAddresses` was the only check
|
|
6
|
+
* anywhere on the path, and neither route into the queue passes it: `send`
|
|
7
|
+
* takes a stored draft, which the update endpoint will happily strip every
|
|
8
|
+
* address off, and `createAndSend` reaches nodemailer with whatever it was
|
|
9
|
+
* handed. Nodemailer refuses an empty envelope inside the SMTP worker, so the
|
|
10
|
+
* message dies in the DLQ and the composer reports a send that went nowhere.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { describe, it } from "node:test";
|
|
15
|
+
import type {
|
|
16
|
+
IAccountRepository,
|
|
17
|
+
IOutboxMessageRepository,
|
|
18
|
+
OutboxMessageItem,
|
|
19
|
+
} from "@remit/data-ports";
|
|
20
|
+
import { BadRequestError } from "@remit/data-ports/errors";
|
|
21
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
22
|
+
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
23
|
+
import { OutboxQueueService } from "./outbox-queue.js";
|
|
24
|
+
|
|
25
|
+
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
26
|
+
const ACCOUNT_ID = "acc-1";
|
|
27
|
+
const OUTBOX_MESSAGE_ID = "ob-1";
|
|
28
|
+
|
|
29
|
+
const draft = (overrides: Partial<OutboxMessageItem>): OutboxMessageItem =>
|
|
30
|
+
({
|
|
31
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
32
|
+
accountId: ACCOUNT_ID,
|
|
33
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
34
|
+
fromAddress: "me@example.com",
|
|
35
|
+
toAddresses: ["them@example.com"],
|
|
36
|
+
ccAddresses: [],
|
|
37
|
+
bccAddresses: [],
|
|
38
|
+
references: [],
|
|
39
|
+
messageIdValue: "<m1@example.com>",
|
|
40
|
+
status: OutboxMessageStatus.draft,
|
|
41
|
+
createdAt: 0,
|
|
42
|
+
updatedAt: 0,
|
|
43
|
+
...overrides,
|
|
44
|
+
}) as OutboxMessageItem;
|
|
45
|
+
|
|
46
|
+
interface Harness {
|
|
47
|
+
service: OutboxQueueService;
|
|
48
|
+
enqueued: string[];
|
|
49
|
+
created: number;
|
|
50
|
+
statusWrites: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
54
|
+
const harness: Harness = {
|
|
55
|
+
service: undefined as unknown as OutboxQueueService,
|
|
56
|
+
enqueued: [],
|
|
57
|
+
created: 0,
|
|
58
|
+
statusWrites: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const outboxMessageService = {
|
|
62
|
+
get: async () => stored,
|
|
63
|
+
create: async (input: Record<string, unknown>) => {
|
|
64
|
+
harness.created += 1;
|
|
65
|
+
return draft(input as Partial<OutboxMessageItem>);
|
|
66
|
+
},
|
|
67
|
+
updateStatus: async (
|
|
68
|
+
_configId: string,
|
|
69
|
+
_id: string,
|
|
70
|
+
status: OutboxMessageItem["status"],
|
|
71
|
+
) => {
|
|
72
|
+
harness.statusWrites.push(status);
|
|
73
|
+
return draft({ status });
|
|
74
|
+
},
|
|
75
|
+
} as unknown as IOutboxMessageRepository;
|
|
76
|
+
|
|
77
|
+
harness.service = new OutboxQueueService({
|
|
78
|
+
outboxMessageService,
|
|
79
|
+
outboxAttachmentService: {} as unknown as OutboxAttachmentService,
|
|
80
|
+
accountService: {} as unknown as IAccountRepository,
|
|
81
|
+
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
82
|
+
sqsClient: {
|
|
83
|
+
send: async (command: { input: { MessageBody: string } }) => {
|
|
84
|
+
harness.enqueued.push(command.input.MessageBody);
|
|
85
|
+
return {};
|
|
86
|
+
},
|
|
87
|
+
} as never,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return harness;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const sendInput = (overrides: Record<string, unknown>) => ({
|
|
94
|
+
accountId: ACCOUNT_ID,
|
|
95
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
96
|
+
fromAddress: "me@example.com",
|
|
97
|
+
toAddresses: [] as string[],
|
|
98
|
+
...overrides,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("OutboxQueueService and a message with nowhere to go", () => {
|
|
102
|
+
it("refuses to queue a stored draft that has lost every address", async () => {
|
|
103
|
+
const harness = createHarness(
|
|
104
|
+
draft({ toAddresses: [], ccAddresses: [], bccAddresses: [] }),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
() => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
109
|
+
(error: unknown) => {
|
|
110
|
+
assert.ok(error instanceof BadRequestError);
|
|
111
|
+
assert.equal(error.statusCode, 400);
|
|
112
|
+
return true;
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(harness.statusWrites, [], "it stayed a draft");
|
|
117
|
+
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("queues a draft addressed only in Bcc — that envelope is real", async () => {
|
|
121
|
+
const harness = createHarness(
|
|
122
|
+
draft({ toAddresses: [], bccAddresses: ["them@example.com"] }),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
126
|
+
|
|
127
|
+
assert.deepEqual(harness.statusWrites, [OutboxMessageStatus.queued]);
|
|
128
|
+
assert.equal(harness.enqueued.length, 1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("refuses a send-immediately create with no addresses, before writing a row", async () => {
|
|
132
|
+
const harness = createHarness(draft({}));
|
|
133
|
+
|
|
134
|
+
await assert.rejects(
|
|
135
|
+
() => harness.service.createAndSend(sendInput({})),
|
|
136
|
+
(error: unknown) => {
|
|
137
|
+
assert.ok(error instanceof BadRequestError);
|
|
138
|
+
assert.equal(error.statusCode, 400);
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
assert.equal(harness.created, 0, "no queued row was left behind");
|
|
144
|
+
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("still creates and queues a send-immediately message that has a recipient", async () => {
|
|
148
|
+
const harness = createHarness(draft({}));
|
|
149
|
+
|
|
150
|
+
await harness.service.createAndSend(
|
|
151
|
+
sendInput({ toAddresses: ["them@example.com"] }),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
assert.equal(harness.created, 1);
|
|
155
|
+
assert.equal(harness.enqueued.length, 1);
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/outbox-queue.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
IOutboxMessageRepository,
|
|
6
6
|
OutboxMessageItem,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import { ConflictError } from "@remit/data-ports/errors";
|
|
8
|
+
import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
|
|
9
9
|
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
10
10
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
11
11
|
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
@@ -64,6 +64,29 @@ export interface UpdateDraftInput {
|
|
|
64
64
|
references?: string[];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Nobody to send to. `@minItems(1)` on the create input is the only thing that
|
|
69
|
+
* has ever stood between a zero-recipient message and nodemailer, which refuses
|
|
70
|
+
* an empty envelope — and refuses it inside the SMTP worker, where the failure
|
|
71
|
+
* lands in the DLQ rather than in front of the person who pressed Send. Neither
|
|
72
|
+
* route into the queue passes that schema: a draft is created with a recipient
|
|
73
|
+
* and can be edited down to none, and `send` never sees the create input at all.
|
|
74
|
+
*
|
|
75
|
+
* Cc and Bcc count. A message addressed only in Bcc is a real message with a
|
|
76
|
+
* real envelope; only a message with no address anywhere has nowhere to go.
|
|
77
|
+
*/
|
|
78
|
+
const hasNowhereToGo = (message: {
|
|
79
|
+
toAddresses?: string[];
|
|
80
|
+
ccAddresses?: string[];
|
|
81
|
+
bccAddresses?: string[];
|
|
82
|
+
}): boolean =>
|
|
83
|
+
(message.toAddresses?.length ?? 0) === 0 &&
|
|
84
|
+
(message.ccAddresses?.length ?? 0) === 0 &&
|
|
85
|
+
(message.bccAddresses?.length ?? 0) === 0;
|
|
86
|
+
|
|
87
|
+
const NO_RECIPIENT_MESSAGE =
|
|
88
|
+
"This message has nobody to send to. Add a recipient before sending it.";
|
|
89
|
+
|
|
67
90
|
const generateMessageId = (domain: string): string => {
|
|
68
91
|
const timestamp = Date.now();
|
|
69
92
|
const random = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
@@ -198,6 +221,10 @@ export class OutboxQueueService {
|
|
|
198
221
|
);
|
|
199
222
|
}
|
|
200
223
|
|
|
224
|
+
if (hasNowhereToGo(existing)) {
|
|
225
|
+
throw new BadRequestError(NO_RECIPIENT_MESSAGE);
|
|
226
|
+
}
|
|
227
|
+
|
|
201
228
|
const updated = await this.outboxMessageService.updateStatus(
|
|
202
229
|
accountConfigId,
|
|
203
230
|
outboxMessageId,
|
|
@@ -217,6 +244,10 @@ export class OutboxQueueService {
|
|
|
217
244
|
createAndSend = async (
|
|
218
245
|
input: CreateDraftInput,
|
|
219
246
|
): Promise<OutboxMessageItem> => {
|
|
247
|
+
if (hasNowhereToGo(input)) {
|
|
248
|
+
throw new BadRequestError(NO_RECIPIENT_MESSAGE);
|
|
249
|
+
}
|
|
250
|
+
|
|
220
251
|
const domain = extractDomain(input.fromAddress);
|
|
221
252
|
const messageIdValue = generateMessageId(domain);
|
|
222
253
|
|
|
@@ -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
|
(
|