@remit/mailbox-service 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
3
|
+
import type {
|
|
4
|
+
IAccountRepository,
|
|
5
|
+
IOutboxMessageRepository,
|
|
6
|
+
OutboxMessageItem,
|
|
7
|
+
} from "@remit/data-ports";
|
|
8
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
9
|
+
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
10
|
+
|
|
11
|
+
interface SendMessageEvent {
|
|
12
|
+
type: "SEND_MESSAGE";
|
|
13
|
+
eventId: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
accountId: string;
|
|
16
|
+
outboxMessageId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OutboxQueueLogger {
|
|
20
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
21
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const noopLogger: OutboxQueueLogger = {
|
|
25
|
+
info: () => {},
|
|
26
|
+
error: () => {},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface OutboxQueueConfig {
|
|
30
|
+
outboxMessageService: IOutboxMessageRepository;
|
|
31
|
+
accountService: IAccountRepository;
|
|
32
|
+
sqsSmtpQueueUrl: string;
|
|
33
|
+
sqsEndpoint?: string;
|
|
34
|
+
logger?: OutboxQueueLogger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CreateDraftInput {
|
|
38
|
+
accountId: string;
|
|
39
|
+
accountConfigId: string;
|
|
40
|
+
fromAddress: string;
|
|
41
|
+
fromName?: string;
|
|
42
|
+
toAddresses: string[];
|
|
43
|
+
ccAddresses?: string[];
|
|
44
|
+
bccAddresses?: string[];
|
|
45
|
+
subject?: string;
|
|
46
|
+
textBody?: string;
|
|
47
|
+
htmlBody?: string;
|
|
48
|
+
inReplyTo?: string;
|
|
49
|
+
references?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UpdateDraftInput {
|
|
53
|
+
toAddresses?: string[];
|
|
54
|
+
ccAddresses?: string[];
|
|
55
|
+
bccAddresses?: string[];
|
|
56
|
+
subject?: string;
|
|
57
|
+
textBody?: string;
|
|
58
|
+
htmlBody?: string;
|
|
59
|
+
inReplyTo?: string;
|
|
60
|
+
references?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const generateMessageId = (domain: string): string => {
|
|
64
|
+
const timestamp = Date.now();
|
|
65
|
+
const random = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
66
|
+
return `${timestamp}.${random}@${domain}`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const extractDomain = (email: string): string => {
|
|
70
|
+
const atIndex = email.lastIndexOf("@");
|
|
71
|
+
if (atIndex === -1) return "localhost";
|
|
72
|
+
return email.slice(atIndex + 1);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export class OutboxQueueService {
|
|
76
|
+
private outboxMessageService: IOutboxMessageRepository;
|
|
77
|
+
private accountService: IAccountRepository;
|
|
78
|
+
private sqs: SQSClient;
|
|
79
|
+
private queueUrl: string;
|
|
80
|
+
private log: OutboxQueueLogger;
|
|
81
|
+
|
|
82
|
+
constructor(config: OutboxQueueConfig) {
|
|
83
|
+
const {
|
|
84
|
+
outboxMessageService,
|
|
85
|
+
accountService,
|
|
86
|
+
sqsSmtpQueueUrl,
|
|
87
|
+
sqsEndpoint,
|
|
88
|
+
} = config;
|
|
89
|
+
this.outboxMessageService = outboxMessageService;
|
|
90
|
+
this.accountService = accountService;
|
|
91
|
+
this.queueUrl = sqsSmtpQueueUrl;
|
|
92
|
+
this.log = config.logger ?? noopLogger;
|
|
93
|
+
|
|
94
|
+
this.sqs = createQueueProducer({
|
|
95
|
+
queueUrl: sqsSmtpQueueUrl,
|
|
96
|
+
endpoint: sqsEndpoint,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
createDraft = async (input: CreateDraftInput): Promise<OutboxMessageItem> => {
|
|
101
|
+
const domain = extractDomain(input.fromAddress);
|
|
102
|
+
const messageIdValue = generateMessageId(domain);
|
|
103
|
+
|
|
104
|
+
const outbox = await this.outboxMessageService.create({
|
|
105
|
+
accountId: input.accountId,
|
|
106
|
+
accountConfigId: input.accountConfigId,
|
|
107
|
+
fromAddress: input.fromAddress,
|
|
108
|
+
fromName: input.fromName,
|
|
109
|
+
toAddresses: input.toAddresses,
|
|
110
|
+
ccAddresses: input.ccAddresses,
|
|
111
|
+
bccAddresses: input.bccAddresses,
|
|
112
|
+
subject: input.subject,
|
|
113
|
+
textBody: input.textBody,
|
|
114
|
+
htmlBody: input.htmlBody,
|
|
115
|
+
inReplyTo: input.inReplyTo,
|
|
116
|
+
references: input.references,
|
|
117
|
+
messageIdValue,
|
|
118
|
+
status: OutboxMessageStatus.draft,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
this.log.info(
|
|
122
|
+
{ outboxMessageId: outbox.outboxMessageId, accountId: input.accountId },
|
|
123
|
+
"Created draft outbox message",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return outbox;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
updateDraft = async (
|
|
130
|
+
accountConfigId: string,
|
|
131
|
+
outboxMessageId: string,
|
|
132
|
+
input: UpdateDraftInput,
|
|
133
|
+
): Promise<OutboxMessageItem> => {
|
|
134
|
+
const existing = await this.outboxMessageService.get(
|
|
135
|
+
accountConfigId,
|
|
136
|
+
outboxMessageId,
|
|
137
|
+
"act",
|
|
138
|
+
);
|
|
139
|
+
if (existing.status !== OutboxMessageStatus.draft) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Cannot update outbox message with status: ${existing.status}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const updated = await this.outboxMessageService.update(
|
|
146
|
+
accountConfigId,
|
|
147
|
+
outboxMessageId,
|
|
148
|
+
{
|
|
149
|
+
...(input.toAddresses !== undefined && {
|
|
150
|
+
toAddresses: input.toAddresses,
|
|
151
|
+
}),
|
|
152
|
+
...(input.ccAddresses !== undefined && {
|
|
153
|
+
ccAddresses: input.ccAddresses,
|
|
154
|
+
}),
|
|
155
|
+
...(input.bccAddresses !== undefined && {
|
|
156
|
+
bccAddresses: input.bccAddresses,
|
|
157
|
+
}),
|
|
158
|
+
...(input.subject !== undefined && { subject: input.subject }),
|
|
159
|
+
...(input.textBody !== undefined && { textBody: input.textBody }),
|
|
160
|
+
...(input.htmlBody !== undefined && { htmlBody: input.htmlBody }),
|
|
161
|
+
...(input.inReplyTo !== undefined && { inReplyTo: input.inReplyTo }),
|
|
162
|
+
...(input.references !== undefined && {
|
|
163
|
+
references: input.references,
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
this.log.info({ outboxMessageId }, "Updated draft outbox message");
|
|
169
|
+
|
|
170
|
+
return updated;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
send = async (
|
|
174
|
+
accountConfigId: string,
|
|
175
|
+
outboxMessageId: string,
|
|
176
|
+
): Promise<OutboxMessageItem> => {
|
|
177
|
+
const existing = await this.outboxMessageService.get(
|
|
178
|
+
accountConfigId,
|
|
179
|
+
outboxMessageId,
|
|
180
|
+
"act",
|
|
181
|
+
);
|
|
182
|
+
if (
|
|
183
|
+
existing.status !== OutboxMessageStatus.draft &&
|
|
184
|
+
existing.status !== OutboxMessageStatus.failed &&
|
|
185
|
+
existing.status !== OutboxMessageStatus.blocked
|
|
186
|
+
) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Cannot send outbox message with status: ${existing.status}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const updated = await this.outboxMessageService.updateStatus(
|
|
193
|
+
accountConfigId,
|
|
194
|
+
outboxMessageId,
|
|
195
|
+
OutboxMessageStatus.queued,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await this.enqueueSend(existing.accountId, outboxMessageId);
|
|
199
|
+
|
|
200
|
+
this.log.info(
|
|
201
|
+
{ outboxMessageId, accountId: existing.accountId },
|
|
202
|
+
"Queued outbox message for sending",
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return updated;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
createAndSend = async (
|
|
209
|
+
input: CreateDraftInput,
|
|
210
|
+
): Promise<OutboxMessageItem> => {
|
|
211
|
+
const domain = extractDomain(input.fromAddress);
|
|
212
|
+
const messageIdValue = generateMessageId(domain);
|
|
213
|
+
|
|
214
|
+
const outbox = await this.outboxMessageService.create({
|
|
215
|
+
accountId: input.accountId,
|
|
216
|
+
accountConfigId: input.accountConfigId,
|
|
217
|
+
fromAddress: input.fromAddress,
|
|
218
|
+
fromName: input.fromName,
|
|
219
|
+
toAddresses: input.toAddresses,
|
|
220
|
+
ccAddresses: input.ccAddresses,
|
|
221
|
+
bccAddresses: input.bccAddresses,
|
|
222
|
+
subject: input.subject,
|
|
223
|
+
textBody: input.textBody,
|
|
224
|
+
htmlBody: input.htmlBody,
|
|
225
|
+
inReplyTo: input.inReplyTo,
|
|
226
|
+
references: input.references,
|
|
227
|
+
messageIdValue,
|
|
228
|
+
status: OutboxMessageStatus.queued,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await this.enqueueSend(input.accountId, outbox.outboxMessageId);
|
|
232
|
+
|
|
233
|
+
this.log.info(
|
|
234
|
+
{ outboxMessageId: outbox.outboxMessageId, accountId: input.accountId },
|
|
235
|
+
"Created and queued outbox message for sending",
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return outbox;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
deleteDraft = async (
|
|
242
|
+
accountConfigId: string,
|
|
243
|
+
outboxMessageId: string,
|
|
244
|
+
): Promise<void> => {
|
|
245
|
+
const existing = await this.outboxMessageService.get(
|
|
246
|
+
accountConfigId,
|
|
247
|
+
outboxMessageId,
|
|
248
|
+
"act",
|
|
249
|
+
);
|
|
250
|
+
if (
|
|
251
|
+
existing.status !== OutboxMessageStatus.draft &&
|
|
252
|
+
existing.status !== OutboxMessageStatus.failed &&
|
|
253
|
+
existing.status !== OutboxMessageStatus.blocked
|
|
254
|
+
) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Cannot delete outbox message with status: ${existing.status}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await this.outboxMessageService.delete(accountConfigId, outboxMessageId);
|
|
261
|
+
|
|
262
|
+
this.log.info({ outboxMessageId }, "Deleted outbox message");
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
private enqueueSend = async (
|
|
266
|
+
accountId: string,
|
|
267
|
+
outboxMessageId: string,
|
|
268
|
+
): Promise<void> => {
|
|
269
|
+
const event: SendMessageEvent = {
|
|
270
|
+
type: "SEND_MESSAGE",
|
|
271
|
+
eventId: randomUUID(),
|
|
272
|
+
timestamp: Date.now(),
|
|
273
|
+
accountId,
|
|
274
|
+
outboxMessageId,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const useFifo = this.queueUrl.endsWith(".fifo");
|
|
278
|
+
await this.sqs.send(
|
|
279
|
+
new SendMessageCommand({
|
|
280
|
+
QueueUrl: this.queueUrl,
|
|
281
|
+
MessageBody: JSON.stringify(event),
|
|
282
|
+
...(useFifo && {
|
|
283
|
+
MessageGroupId: accountId,
|
|
284
|
+
MessageDeduplicationId: event.eventId,
|
|
285
|
+
}),
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
this.log.info(
|
|
290
|
+
{ eventId: event.eventId, accountId, outboxMessageId },
|
|
291
|
+
"Enqueued SEND_MESSAGE event",
|
|
292
|
+
);
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs the write set against fixed repositories with no surrounding
|
|
5
|
+
* transaction. For backends (DynamoDB) that have no cross-entity transaction:
|
|
6
|
+
* the writes are not atomic, matching that backend's own guarantees. The
|
|
7
|
+
* Postgres path injects a real transactional unit of work instead.
|
|
8
|
+
*/
|
|
9
|
+
export class PassThroughUnitOfWork implements IUnitOfWork {
|
|
10
|
+
constructor(private repos: UnitOfWorkRepositories) {}
|
|
11
|
+
|
|
12
|
+
transaction<T>(
|
|
13
|
+
fn: (repos: UnitOfWorkRepositories) => Promise<T>,
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
return fn(this.repos);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IMessageRepository,
|
|
5
|
+
IThreadMessageRepository,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import type { PlacementMoveLogger } from "./placement-move.js";
|
|
8
|
+
import {
|
|
9
|
+
type ResolveExhaustedPlacementMoveDeps,
|
|
10
|
+
resolveExhaustedPlacementMoveFailure,
|
|
11
|
+
} from "./placement-move-terminal.js";
|
|
12
|
+
import type { IImapConnection } from "./types.js";
|
|
13
|
+
|
|
14
|
+
interface LogEntry {
|
|
15
|
+
obj: Record<string, unknown>;
|
|
16
|
+
msg: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const buildLogger = (): {
|
|
20
|
+
log: PlacementMoveLogger;
|
|
21
|
+
infos: LogEntry[];
|
|
22
|
+
errors: LogEntry[];
|
|
23
|
+
} => {
|
|
24
|
+
const infos: LogEntry[] = [];
|
|
25
|
+
const errors: LogEntry[] = [];
|
|
26
|
+
return {
|
|
27
|
+
log: {
|
|
28
|
+
info: (obj, msg) => infos.push({ obj, msg }),
|
|
29
|
+
error: (obj, msg) => errors.push({ obj, msg }),
|
|
30
|
+
},
|
|
31
|
+
infos,
|
|
32
|
+
errors,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const buildConnection = (present: Set<number>): IImapConnection =>
|
|
37
|
+
({
|
|
38
|
+
openBox: async () => ({}) as never,
|
|
39
|
+
fetchMessages: async (uids: number[]) =>
|
|
40
|
+
uids
|
|
41
|
+
.filter((uid) => present.has(uid))
|
|
42
|
+
.map((uid) => ({ uid }) as unknown as never),
|
|
43
|
+
}) as unknown as IImapConnection;
|
|
44
|
+
|
|
45
|
+
describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mirrors #1270 for placement moves)", () => {
|
|
46
|
+
it("RECONCILED (expected): the message is gone from its pending-move source — marker dropped, stale row reconciled, no alarm", async () => {
|
|
47
|
+
const deletedMessages: string[] = [];
|
|
48
|
+
const deletedThreadMessages: Array<{
|
|
49
|
+
accountConfigId: string;
|
|
50
|
+
threadMessageId: string;
|
|
51
|
+
}> = [];
|
|
52
|
+
const markerDeletes: string[] = [];
|
|
53
|
+
const { log, infos, errors } = buildLogger();
|
|
54
|
+
|
|
55
|
+
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
56
|
+
markerService: {
|
|
57
|
+
delete: async (id: string) => {
|
|
58
|
+
markerDeletes.push(id);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
messageService: {
|
|
62
|
+
delete: async (id: string) => {
|
|
63
|
+
deletedMessages.push(id);
|
|
64
|
+
},
|
|
65
|
+
} as unknown as Pick<IMessageRepository, "delete">,
|
|
66
|
+
threadMessageService: {
|
|
67
|
+
findAllByMessageId: async (
|
|
68
|
+
accountConfigId: string,
|
|
69
|
+
messageId: string,
|
|
70
|
+
) => [{ accountConfigId, threadMessageId: `tm-${messageId}` }],
|
|
71
|
+
deleteMany: async (
|
|
72
|
+
keys: Array<{ accountConfigId: string; threadMessageId: string }>,
|
|
73
|
+
) => {
|
|
74
|
+
deletedThreadMessages.push(...keys);
|
|
75
|
+
},
|
|
76
|
+
} as unknown as Pick<
|
|
77
|
+
IThreadMessageRepository,
|
|
78
|
+
"findAllByMessageId" | "deleteMany"
|
|
79
|
+
>,
|
|
80
|
+
log,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = await resolveExhaustedPlacementMoveFailure(deps, {
|
|
84
|
+
accountId: "acc-1",
|
|
85
|
+
accountConfigId: "cfg-1",
|
|
86
|
+
messageId: "msg-gone",
|
|
87
|
+
uid: 101,
|
|
88
|
+
sourceMailboxPath: "Junk",
|
|
89
|
+
getConnection: async () => buildConnection(new Set()),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.equal(result.outcome, "reconciled");
|
|
93
|
+
assert.deepEqual(markerDeletes, ["msg-gone"]);
|
|
94
|
+
assert.deepEqual(deletedMessages, ["msg-gone"]);
|
|
95
|
+
assert.deepEqual(deletedThreadMessages, [
|
|
96
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-gone" },
|
|
97
|
+
]);
|
|
98
|
+
assert.equal(errors.length, 0, "no alarm for the expected/routine outcome");
|
|
99
|
+
assert.ok(
|
|
100
|
+
infos.some((e) => e.obj.metric === "placement_move_stale_row_reconciled"),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("BROKEN: the message still exists at the source — marker LEFT PENDING (never dropped), alarm logged, never re-thrown", async () => {
|
|
105
|
+
const deletedMessages: string[] = [];
|
|
106
|
+
const markerDeletes: string[] = [];
|
|
107
|
+
const { log, errors } = buildLogger();
|
|
108
|
+
|
|
109
|
+
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
110
|
+
markerService: {
|
|
111
|
+
delete: async (id: string) => {
|
|
112
|
+
markerDeletes.push(id);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
messageService: {
|
|
116
|
+
delete: async (id: string) => {
|
|
117
|
+
deletedMessages.push(id);
|
|
118
|
+
},
|
|
119
|
+
} as unknown as Pick<IMessageRepository, "delete">,
|
|
120
|
+
threadMessageService: {
|
|
121
|
+
findAllByMessageId: async () => [],
|
|
122
|
+
deleteMany: async () => {},
|
|
123
|
+
} as unknown as Pick<
|
|
124
|
+
IThreadMessageRepository,
|
|
125
|
+
"findAllByMessageId" | "deleteMany"
|
|
126
|
+
>,
|
|
127
|
+
log,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const result = await resolveExhaustedPlacementMoveFailure(deps, {
|
|
131
|
+
accountId: "acc-1",
|
|
132
|
+
accountConfigId: "cfg-1",
|
|
133
|
+
messageId: "msg-broken",
|
|
134
|
+
uid: 202,
|
|
135
|
+
sourceMailboxPath: "INBOX",
|
|
136
|
+
getConnection: async () => buildConnection(new Set([202])),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
assert.equal(result.outcome, "broken");
|
|
140
|
+
// The marker is the lock that stops a resync from correcting the message
|
|
141
|
+
// back to its server location (rule 3) — dropping it here would remove
|
|
142
|
+
// that protection while the move is still unresolved.
|
|
143
|
+
assert.deepEqual(markerDeletes, []);
|
|
144
|
+
assert.deepEqual(deletedMessages, []);
|
|
145
|
+
assert.ok(
|
|
146
|
+
errors.some((e) => e.obj.alert === "placement_move_failed"),
|
|
147
|
+
"expected an alert-shaped log for the broken case",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("never throws — both outcomes are terminal, the caller always acks", async () => {
|
|
152
|
+
const { log } = buildLogger();
|
|
153
|
+
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
154
|
+
markerService: { delete: async () => {} },
|
|
155
|
+
messageService: {
|
|
156
|
+
delete: async () => {},
|
|
157
|
+
} as unknown as Pick<IMessageRepository, "delete">,
|
|
158
|
+
threadMessageService: {
|
|
159
|
+
findAllByMessageId: async () => [],
|
|
160
|
+
deleteMany: async () => {},
|
|
161
|
+
} as unknown as Pick<
|
|
162
|
+
IThreadMessageRepository,
|
|
163
|
+
"findAllByMessageId" | "deleteMany"
|
|
164
|
+
>,
|
|
165
|
+
log,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
await assert.doesNotReject(() =>
|
|
169
|
+
resolveExhaustedPlacementMoveFailure(deps, {
|
|
170
|
+
accountId: "acc-1",
|
|
171
|
+
accountConfigId: "cfg-1",
|
|
172
|
+
messageId: "msg-x",
|
|
173
|
+
uid: 1,
|
|
174
|
+
sourceMailboxPath: "INBOX",
|
|
175
|
+
getConnection: async () => buildConnection(new Set([1])),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { PlacementMoveLogger } from "./placement-move.js";
|
|
2
|
+
import {
|
|
3
|
+
reconcileStaleMessage,
|
|
4
|
+
type StaleMessageReconcileDeps,
|
|
5
|
+
} from "./stale-message-reconcile.js";
|
|
6
|
+
import type { IImapConnection } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export interface ResolveExhaustedPlacementMoveDeps
|
|
9
|
+
extends StaleMessageReconcileDeps {
|
|
10
|
+
markerService: { delete(messageId: string): Promise<void> };
|
|
11
|
+
log: PlacementMoveLogger;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ResolveExhaustedPlacementMoveInput {
|
|
15
|
+
accountId: string;
|
|
16
|
+
accountConfigId: string;
|
|
17
|
+
messageId: string;
|
|
18
|
+
uid: number;
|
|
19
|
+
sourceMailboxPath: string;
|
|
20
|
+
getConnection: () => Promise<IImapConnection>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PlacementMoveTerminalOutcome = "reconciled" | "broken";
|
|
24
|
+
|
|
25
|
+
export interface ResolveExhaustedPlacementMoveResult {
|
|
26
|
+
outcome: PlacementMoveTerminalOutcome;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a PLACEMENT_MOVE_PUSH failure that has exhausted the placement-move
|
|
31
|
+
* queue's redelivery budget into exactly one of two terminal outcomes,
|
|
32
|
+
* mirroring `resolveExhaustedBodySyncFailures` (#1270) for the same failure
|
|
33
|
+
* taxonomy (epic #1281 invariant 3) — no third, softer outcome.
|
|
34
|
+
*
|
|
35
|
+
* 1. RECONCILED (expected) — the message no longer exists at its pending-move
|
|
36
|
+
* source on IMAP. Per invariant 2, an external delete supersedes the
|
|
37
|
+
* marker entirely: the marker is dropped and the stale Message/ThreadMessage
|
|
38
|
+
* rows are deleted via {@link reconcileStaleMessage}. This is also the
|
|
39
|
+
* outcome for the (rarer, functionally indistinguishable from here) case
|
|
40
|
+
* where a foreign client moved the message elsewhere — either way, our
|
|
41
|
+
* prediction no longer holds, and the marker cannot be honoured. Metric
|
|
42
|
+
* only, no alarm — routine.
|
|
43
|
+
* 2. BROKEN — the message still exists at the source, but the move keeps
|
|
44
|
+
* failing. This indicates broken code or a broken account (issue #1271),
|
|
45
|
+
* not a transient blip. The marker is left in place (not cleared) so a
|
|
46
|
+
* later resync can never "correct" the message back to its server
|
|
47
|
+
* location out from under an operator's investigation — dropping it here
|
|
48
|
+
* would remove the very lock rule 3 depends on. Logged with an
|
|
49
|
+
* `alert`-shaped entry for an operator alarm; never re-thrown (terminal —
|
|
50
|
+
* the caller acks either way, since retrying a stale or permanently-broken
|
|
51
|
+
* move can never succeed).
|
|
52
|
+
*/
|
|
53
|
+
export const resolveExhaustedPlacementMoveFailure = async (
|
|
54
|
+
deps: ResolveExhaustedPlacementMoveDeps,
|
|
55
|
+
input: ResolveExhaustedPlacementMoveInput,
|
|
56
|
+
): Promise<ResolveExhaustedPlacementMoveResult> => {
|
|
57
|
+
const {
|
|
58
|
+
accountId,
|
|
59
|
+
accountConfigId,
|
|
60
|
+
messageId,
|
|
61
|
+
uid,
|
|
62
|
+
sourceMailboxPath,
|
|
63
|
+
getConnection,
|
|
64
|
+
} = input;
|
|
65
|
+
|
|
66
|
+
const connection = await getConnection();
|
|
67
|
+
await connection.openBox(sourceMailboxPath);
|
|
68
|
+
const found = await connection.fetchMessages([uid]);
|
|
69
|
+
|
|
70
|
+
if (found.length === 0) {
|
|
71
|
+
await deps.markerService.delete(messageId);
|
|
72
|
+
const { threadMessagesDeleted } = await reconcileStaleMessage(
|
|
73
|
+
deps,
|
|
74
|
+
accountConfigId,
|
|
75
|
+
messageId,
|
|
76
|
+
);
|
|
77
|
+
deps.log.info(
|
|
78
|
+
{
|
|
79
|
+
metric: "placement_move_stale_row_reconciled",
|
|
80
|
+
accountId,
|
|
81
|
+
accountConfigId,
|
|
82
|
+
messageId,
|
|
83
|
+
uid,
|
|
84
|
+
sourceMailboxPath,
|
|
85
|
+
threadMessagesDeleted,
|
|
86
|
+
},
|
|
87
|
+
"Message no longer at its pending-move source after retry exhaustion (external delete or move); marker dropped, stale row reconciled",
|
|
88
|
+
);
|
|
89
|
+
return { outcome: "reconciled" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
deps.log.error(
|
|
93
|
+
{
|
|
94
|
+
alert: "placement_move_failed",
|
|
95
|
+
accountId,
|
|
96
|
+
accountConfigId,
|
|
97
|
+
messageId,
|
|
98
|
+
uid,
|
|
99
|
+
sourceMailboxPath,
|
|
100
|
+
},
|
|
101
|
+
"Placement move could not be pushed to IMAP after retry exhaustion; message still exists at its source — marker left pending for operator investigation",
|
|
102
|
+
);
|
|
103
|
+
return { outcome: "broken" };
|
|
104
|
+
};
|