@remit/mailbox-service 0.0.111 → 0.0.112
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 +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +156 -235
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +5 -4
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-message-gone.test.ts +0 -139
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
|
@@ -1,419 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
IAddressRepository,
|
|
5
|
-
IEnvelopeRepository,
|
|
6
|
-
IMailboxRepository,
|
|
7
|
-
IMessageRepository,
|
|
8
|
-
IThreadMessageRepository,
|
|
9
|
-
MailboxItem,
|
|
10
|
-
MessageItem,
|
|
11
|
-
ThreadMessageItem,
|
|
12
|
-
UpdateThreadMessageInput,
|
|
13
|
-
} from "@remit/data-ports";
|
|
14
|
-
import { deriveMessageId } from "@remit/data-ports/id";
|
|
15
|
-
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
16
|
-
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
17
|
-
import { type DepartureVerdicts, placementKey } from "./external-move.js";
|
|
18
|
-
import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
|
|
19
|
-
import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
20
|
-
import type { ImapEnvelope, ImapMessage } from "./types.js";
|
|
21
|
-
|
|
22
|
-
const stub = <T>(): T => ({}) as T;
|
|
23
|
-
|
|
24
|
-
const envelope: ImapEnvelope = {
|
|
25
|
-
date: new Date(0).toISOString(),
|
|
26
|
-
messageId: "<root@example.com>",
|
|
27
|
-
subject: "Subject",
|
|
28
|
-
from: [{ name: "Pharma Deals", mailbox: "sales", host: "pharma.example" }],
|
|
29
|
-
sender: [],
|
|
30
|
-
replyTo: [],
|
|
31
|
-
to: [{ name: "", mailbox: "victim", host: "ischen.nl" }],
|
|
32
|
-
cc: [],
|
|
33
|
-
bcc: [],
|
|
34
|
-
inReplyTo: "",
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const mailboxAt = (fullPath: string, specialUse?: string[]): MailboxItem =>
|
|
38
|
-
({
|
|
39
|
-
mailboxId: `mbx-${fullPath}`,
|
|
40
|
-
fullPath,
|
|
41
|
-
hierarchyDelimiter: "/",
|
|
42
|
-
...(specialUse ? { specialUse } : {}),
|
|
43
|
-
}) as MailboxItem;
|
|
44
|
-
|
|
45
|
-
const INBOX = mailboxAt("INBOX");
|
|
46
|
-
const JUNK = mailboxAt("INBOX/Rubbish");
|
|
47
|
-
const TRASH = mailboxAt("Trash");
|
|
48
|
-
const ALL_MAIL = mailboxAt("[Gmail]/All Mail", ["All"]);
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* `pending` is what an ordinary inbound row carries: `upsertWithStatus` is
|
|
52
|
-
* called without a `syncStatus` and the repository defaults to it, and nothing
|
|
53
|
-
* on the sync path ever promotes it (#1096). The fixture states the reachable
|
|
54
|
-
* state, not the one the gate used to demand.
|
|
55
|
-
*/
|
|
56
|
-
const storedIn = (
|
|
57
|
-
mailbox: MailboxItem,
|
|
58
|
-
overrides: Partial<MessageItem> = {},
|
|
59
|
-
): MessageItem =>
|
|
60
|
-
({
|
|
61
|
-
mailboxId: mailbox.mailboxId,
|
|
62
|
-
uid: 7,
|
|
63
|
-
status: MessageStatus.active,
|
|
64
|
-
syncStatus: MessageSyncStatus.pending,
|
|
65
|
-
...overrides,
|
|
66
|
-
}) as MessageItem;
|
|
67
|
-
|
|
68
|
-
const threadRowIn = (mailbox: MailboxItem): ThreadMessageItem =>
|
|
69
|
-
({
|
|
70
|
-
threadMessageId: "tm-1",
|
|
71
|
-
accountConfigId: "cfg-1",
|
|
72
|
-
mailboxId: mailbox.mailboxId,
|
|
73
|
-
uid: 7,
|
|
74
|
-
sentDate: 0,
|
|
75
|
-
isRead: false,
|
|
76
|
-
isDeleted: mailbox === TRASH,
|
|
77
|
-
hasStars: false,
|
|
78
|
-
hasAttachment: false,
|
|
79
|
-
}) as ThreadMessageItem;
|
|
80
|
-
|
|
81
|
-
interface Observed {
|
|
82
|
-
repointedTo: Array<{ mailboxId: string; uid: number }>;
|
|
83
|
-
threadUpdates: UpdateThreadMessageInput[];
|
|
84
|
-
reconciled: string[];
|
|
85
|
-
owned: boolean;
|
|
86
|
-
unresolved: boolean;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* What `confirmDepartures` had answered about the stored placement by the time
|
|
91
|
-
* the batch was saved (#1146).
|
|
92
|
-
*
|
|
93
|
-
* - `departed` — the source folder no longer holds it: a move another client made.
|
|
94
|
-
* - `held` — the source folder still holds it: the labelled message, in two
|
|
95
|
-
* folders at once.
|
|
96
|
-
* - `none` — no verdict at all: the source could not be asked this round.
|
|
97
|
-
* - `stale` — a verdict, but for a placement this row no longer has, because it
|
|
98
|
-
* moved between the probe and the save.
|
|
99
|
-
*/
|
|
100
|
-
type Verdict = "departed" | "held" | "none" | "stale";
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Sync one message out of `sighting`, against a database that already holds it
|
|
104
|
-
* under `stored`. The account's Junk folder is `JUNK` and its Trash is `TRASH`,
|
|
105
|
-
* so the sighting's own role follows from which mailbox it is.
|
|
106
|
-
*/
|
|
107
|
-
const sync = async (
|
|
108
|
-
sighting: MailboxItem,
|
|
109
|
-
stored: MessageItem,
|
|
110
|
-
threadRow: ThreadMessageItem | null = threadRowIn(INBOX),
|
|
111
|
-
copiesHere: MessageItem[] = [],
|
|
112
|
-
verdict: Verdict = "departed",
|
|
113
|
-
): Promise<Observed> => {
|
|
114
|
-
const observed: Observed = {
|
|
115
|
-
repointedTo: [],
|
|
116
|
-
threadUpdates: [],
|
|
117
|
-
reconciled: [],
|
|
118
|
-
owned: false,
|
|
119
|
-
unresolved: false,
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const messageService = {
|
|
123
|
-
upsertWithStatus: async () => ({ item: stored, created: false }),
|
|
124
|
-
get: async () => copiesHere,
|
|
125
|
-
updateUid: async (_id: string, uid: number, mailboxId: string) => {
|
|
126
|
-
observed.repointedTo.push({ mailboxId, uid });
|
|
127
|
-
return stored;
|
|
128
|
-
},
|
|
129
|
-
} as unknown as IMessageRepository;
|
|
130
|
-
|
|
131
|
-
const threadMessageService = {
|
|
132
|
-
findByMessageId: async () => threadRow,
|
|
133
|
-
update: async (
|
|
134
|
-
_config: string,
|
|
135
|
-
_id: string,
|
|
136
|
-
input: UpdateThreadMessageInput,
|
|
137
|
-
) => {
|
|
138
|
-
observed.threadUpdates.push(input);
|
|
139
|
-
return threadRow as ThreadMessageItem;
|
|
140
|
-
},
|
|
141
|
-
create: async (input: unknown) => input as ThreadMessageItem,
|
|
142
|
-
} as unknown as IThreadMessageRepository;
|
|
143
|
-
|
|
144
|
-
const envelopeService = {
|
|
145
|
-
upsertEnvelope: async () => {},
|
|
146
|
-
upsertBodyParts: async () => {},
|
|
147
|
-
} as unknown as IEnvelopeRepository;
|
|
148
|
-
|
|
149
|
-
const addressService = {
|
|
150
|
-
upsertCorrespondentAddress: async () => {},
|
|
151
|
-
upsertJunkAddress: async () => {},
|
|
152
|
-
upsertAddress: async () => {},
|
|
153
|
-
upsertEnvelopeAddress: async () => {},
|
|
154
|
-
reconcileJunkOnlyForMessage: async (messageId: string) => {
|
|
155
|
-
observed.reconciled.push(messageId);
|
|
156
|
-
},
|
|
157
|
-
} as unknown as IAddressRepository;
|
|
158
|
-
|
|
159
|
-
const service = new MessageSyncService(
|
|
160
|
-
stub<ManagedConnectionFactory>(),
|
|
161
|
-
stub<IMailboxRepository>(),
|
|
162
|
-
folderRoles({
|
|
163
|
-
junkMailboxId: JUNK.mailboxId,
|
|
164
|
-
trashMailboxId: TRASH.mailboxId,
|
|
165
|
-
}),
|
|
166
|
-
messageService,
|
|
167
|
-
envelopeService,
|
|
168
|
-
addressService,
|
|
169
|
-
threadMessageService,
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
const msg = {
|
|
173
|
-
uid: 42,
|
|
174
|
-
seq: 1,
|
|
175
|
-
size: 100,
|
|
176
|
-
internalDate: new Date(0),
|
|
177
|
-
flags: [],
|
|
178
|
-
envelope,
|
|
179
|
-
} as unknown as ImapMessage;
|
|
180
|
-
|
|
181
|
-
const answeredFor = placementKey(
|
|
182
|
-
deriveMessageId("acct-1", envelope.messageId),
|
|
183
|
-
stored.mailboxId,
|
|
184
|
-
verdict === "stale" ? stored.uid + 1 : stored.uid,
|
|
185
|
-
);
|
|
186
|
-
const departures: DepartureVerdicts = {
|
|
187
|
-
departed:
|
|
188
|
-
verdict === "held" || verdict === "none"
|
|
189
|
-
? new Set()
|
|
190
|
-
: new Set([answeredFor]),
|
|
191
|
-
settled: verdict === "none" ? new Set() : new Set([answeredFor]),
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const result = (await (
|
|
195
|
-
service as unknown as {
|
|
196
|
-
saveMessage: (
|
|
197
|
-
mailbox: MailboxItem,
|
|
198
|
-
accountId: string,
|
|
199
|
-
accountConfigId: string,
|
|
200
|
-
msg: ImapMessage,
|
|
201
|
-
roles: AccountFolderRoles,
|
|
202
|
-
departures: DepartureVerdicts,
|
|
203
|
-
) => Promise<{ owned: boolean; unresolvedSighting: boolean }>;
|
|
204
|
-
}
|
|
205
|
-
).saveMessage(
|
|
206
|
-
sighting,
|
|
207
|
-
"acct-1",
|
|
208
|
-
"cfg-1",
|
|
209
|
-
msg,
|
|
210
|
-
{
|
|
211
|
-
junkMailboxId: JUNK.mailboxId,
|
|
212
|
-
trashMailboxId: TRASH.mailboxId,
|
|
213
|
-
configJunkRoles: NO_JUNK_ROLES,
|
|
214
|
-
},
|
|
215
|
-
departures,
|
|
216
|
-
)) as { owned: boolean; unresolvedSighting: boolean };
|
|
217
|
-
|
|
218
|
-
observed.owned = result.owned;
|
|
219
|
-
observed.unresolved = result.unresolvedSighting;
|
|
220
|
-
return observed;
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
describe("which folder a message the database already holds lives in", () => {
|
|
224
|
-
it("follows the last move when another client files it into Junk", async () => {
|
|
225
|
-
const observed = await sync(JUNK, storedIn(INBOX));
|
|
226
|
-
|
|
227
|
-
assert.deepEqual(observed.repointedTo, [
|
|
228
|
-
{ mailboxId: JUNK.mailboxId, uid: 42 },
|
|
229
|
-
]);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("follows the last move back when another client rescues it", async () => {
|
|
233
|
-
const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
|
|
234
|
-
|
|
235
|
-
assert.deepEqual(observed.repointedTo, [
|
|
236
|
-
{ mailboxId: INBOX.mailboxId, uid: 42 },
|
|
237
|
-
]);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it("takes the UID of the folder that issued it, never the old one", async () => {
|
|
241
|
-
const observed = await sync(JUNK, storedIn(INBOX, { uid: 7 }));
|
|
242
|
-
|
|
243
|
-
assert.equal(observed.repointedTo[0].uid, 42);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("leaves a row this folder already owns alone", async () => {
|
|
247
|
-
const observed = await sync(INBOX, storedIn(INBOX));
|
|
248
|
-
|
|
249
|
-
assert.deepEqual(observed.repointedTo, []);
|
|
250
|
-
assert.equal(observed.owned, true);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("holds a row whose own move has not settled", async () => {
|
|
254
|
-
const observed = await sync(
|
|
255
|
-
INBOX,
|
|
256
|
-
storedIn(JUNK, {
|
|
257
|
-
status: MessageStatus.moving,
|
|
258
|
-
syncStatus: MessageSyncStatus.pending,
|
|
259
|
-
}),
|
|
260
|
-
);
|
|
261
|
-
|
|
262
|
-
assert.deepEqual(observed.repointedTo, []);
|
|
263
|
-
assert.equal(observed.owned, false);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* The reported bug (#1146): a message that legitimately sits in two real
|
|
268
|
-
* folders — a Gmail user label, a Sieve `fileinto` beside a `keep`, a Sent
|
|
269
|
-
* copy a list echoes back — was re-pointed out of the Inbox by whichever
|
|
270
|
-
* folder the round enumerated last.
|
|
271
|
-
*/
|
|
272
|
-
it("leaves a labelled message in the folder that still holds it", async () => {
|
|
273
|
-
const observed = await sync(
|
|
274
|
-
mailboxAt("Receipts"),
|
|
275
|
-
storedIn(INBOX),
|
|
276
|
-
threadRowIn(INBOX),
|
|
277
|
-
[],
|
|
278
|
-
"held",
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
assert.deepEqual(observed.repointedTo, []);
|
|
282
|
-
assert.deepEqual(observed.threadUpdates, []);
|
|
283
|
-
assert.equal(observed.owned, false);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* A decided sighting is decided for good: the message is in two folders and
|
|
288
|
-
* asking again next round would get the same answer. Only an UNDECIDED one
|
|
289
|
-
* comes back, so a settled decline must not hold the watermark.
|
|
290
|
-
*/
|
|
291
|
-
it("finishes with a labelled message rather than holding the watermark for it", async () => {
|
|
292
|
-
const observed = await sync(
|
|
293
|
-
mailboxAt("Receipts"),
|
|
294
|
-
storedIn(INBOX),
|
|
295
|
-
threadRowIn(INBOX),
|
|
296
|
-
[],
|
|
297
|
-
"held",
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
assert.equal(observed.unresolved, false);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it("holds the watermark when the source folder could not be asked", async () => {
|
|
304
|
-
const observed = await sync(
|
|
305
|
-
JUNK,
|
|
306
|
-
storedIn(INBOX),
|
|
307
|
-
threadRowIn(INBOX),
|
|
308
|
-
[],
|
|
309
|
-
"none",
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
assert.deepEqual(observed.repointedTo, []);
|
|
313
|
-
assert.equal(observed.unresolved, true);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* The probe reads the row outside the transaction that acts on it, so a user
|
|
318
|
-
* move landing in between leaves a verdict about a placement this row no
|
|
319
|
-
* longer has. Spending it would re-point the row against an answer reached
|
|
320
|
-
* for somewhere else.
|
|
321
|
-
*/
|
|
322
|
-
it("spends no verdict reached for a placement the row has since left", async () => {
|
|
323
|
-
const observed = await sync(
|
|
324
|
-
JUNK,
|
|
325
|
-
storedIn(INBOX),
|
|
326
|
-
threadRowIn(INBOX),
|
|
327
|
-
[],
|
|
328
|
-
"stale",
|
|
329
|
-
);
|
|
330
|
-
|
|
331
|
-
assert.deepEqual(observed.repointedTo, []);
|
|
332
|
-
assert.equal(observed.unresolved, true);
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it("holds the watermark for nothing when the row is already ours", async () => {
|
|
336
|
-
const observed = await sync(
|
|
337
|
-
INBOX,
|
|
338
|
-
storedIn(INBOX),
|
|
339
|
-
threadRowIn(INBOX),
|
|
340
|
-
[],
|
|
341
|
-
"none",
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
assert.equal(observed.unresolved, false);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it("declines a sighting in a folder that copies every message", async () => {
|
|
348
|
-
const observed = await sync(ALL_MAIL, storedIn(INBOX));
|
|
349
|
-
|
|
350
|
-
assert.deepEqual(observed.repointedTo, []);
|
|
351
|
-
assert.equal(observed.owned, false);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
it("declines a sighting of a copy the user put in this folder", async () => {
|
|
355
|
-
const observed = await sync(JUNK, storedIn(INBOX), threadRowIn(INBOX), [
|
|
356
|
-
storedIn(JUNK),
|
|
357
|
-
]);
|
|
358
|
-
|
|
359
|
-
assert.deepEqual(observed.repointedTo, []);
|
|
360
|
-
assert.deepEqual(observed.threadUpdates, []);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
it("hands the re-pointed message to this folder's body sync", async () => {
|
|
364
|
-
const observed = await sync(JUNK, storedIn(INBOX));
|
|
365
|
-
|
|
366
|
-
assert.equal(observed.owned, true);
|
|
367
|
-
});
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
describe("what a re-pointed message does to the listing that renders it", () => {
|
|
371
|
-
it("moves the row the listing and the folder counts read", async () => {
|
|
372
|
-
const observed = await sync(JUNK, storedIn(INBOX));
|
|
373
|
-
|
|
374
|
-
assert.deepEqual(observed.threadUpdates, [
|
|
375
|
-
{ mailboxId: JUNK.mailboxId, uid: 42, isDeleted: false },
|
|
376
|
-
]);
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
it("marks the row deleted when the move was into Trash", async () => {
|
|
380
|
-
const observed = await sync(TRASH, storedIn(INBOX));
|
|
381
|
-
|
|
382
|
-
assert.equal(observed.threadUpdates[0].isDeleted, true);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("clears the deleted mark when the move was out of Trash", async () => {
|
|
386
|
-
const observed = await sync(INBOX, storedIn(TRASH), threadRowIn(TRASH));
|
|
387
|
-
|
|
388
|
-
assert.equal(observed.threadUpdates[0].isDeleted, false);
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it("writes nothing when no row renders the message yet", async () => {
|
|
392
|
-
const observed = await sync(JUNK, storedIn(INBOX), null);
|
|
393
|
-
|
|
394
|
-
assert.deepEqual(observed.threadUpdates, []);
|
|
395
|
-
assert.deepEqual(observed.repointedTo, [
|
|
396
|
-
{ mailboxId: JUNK.mailboxId, uid: 42 },
|
|
397
|
-
]);
|
|
398
|
-
});
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
describe("what a re-pointed message does to its sender's standing", () => {
|
|
402
|
-
it("re-asks the question when the move was into Junk", async () => {
|
|
403
|
-
const observed = await sync(JUNK, storedIn(INBOX));
|
|
404
|
-
|
|
405
|
-
assert.equal(observed.reconciled.length, 1);
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
it("re-asks it again when the move was back out of Junk", async () => {
|
|
409
|
-
const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
|
|
410
|
-
|
|
411
|
-
assert.equal(observed.reconciled.length, 1);
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it("asks nothing when the pointer did not move", async () => {
|
|
415
|
-
const observed = await sync(INBOX, storedIn(INBOX));
|
|
416
|
-
|
|
417
|
-
assert.deepEqual(observed.reconciled, []);
|
|
418
|
-
});
|
|
419
|
-
});
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A row the SMTP worker settled has to be a row the user can still act on.
|
|
3
|
-
*
|
|
4
|
-
* `sending` is not: `send` takes draft, failed and blocked, and `deleteDraft`
|
|
5
|
-
* those three plus `unfiled`, so a send that never reached the server left a
|
|
6
|
-
* row that 409s on both buttons forever (#951). The worker now settles that
|
|
7
|
-
* row instead of dead-lettering it, and these are the two moves the settled
|
|
8
|
-
* status has to accept for the settle to be worth anything.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import { describe, it } from "node:test";
|
|
13
|
-
import type {
|
|
14
|
-
IAccountRepository,
|
|
15
|
-
IOutboxMessageRepository,
|
|
16
|
-
OutboxMessageItem,
|
|
17
|
-
} from "@remit/data-ports";
|
|
18
|
-
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
19
|
-
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
20
|
-
import { OutboxQueueService } from "./outbox-queue.js";
|
|
21
|
-
|
|
22
|
-
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
23
|
-
const ACCOUNT_ID = "acc-1";
|
|
24
|
-
const OUTBOX_MESSAGE_ID = "ob-1";
|
|
25
|
-
|
|
26
|
-
const row = (status: OutboxMessageItem["status"]): OutboxMessageItem =>
|
|
27
|
-
({
|
|
28
|
-
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
29
|
-
accountId: ACCOUNT_ID,
|
|
30
|
-
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
31
|
-
fromAddress: "me@example.com",
|
|
32
|
-
toAddresses: ["them@example.com"],
|
|
33
|
-
ccAddresses: [],
|
|
34
|
-
bccAddresses: [],
|
|
35
|
-
references: [],
|
|
36
|
-
messageIdValue: "<m1@example.com>",
|
|
37
|
-
lastError: "SMTP connection failed: ECONNREFUSED",
|
|
38
|
-
status,
|
|
39
|
-
createdAt: 0,
|
|
40
|
-
updatedAt: 0,
|
|
41
|
-
}) as unknown as OutboxMessageItem;
|
|
42
|
-
|
|
43
|
-
interface Harness {
|
|
44
|
-
service: OutboxQueueService;
|
|
45
|
-
enqueued: string[];
|
|
46
|
-
statusWrites: string[];
|
|
47
|
-
deleted: string[];
|
|
48
|
-
discarded: string[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
52
|
-
const harness: Harness = {
|
|
53
|
-
service: undefined as unknown as OutboxQueueService,
|
|
54
|
-
enqueued: [],
|
|
55
|
-
statusWrites: [],
|
|
56
|
-
deleted: [],
|
|
57
|
-
discarded: [],
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const outboxMessageService = {
|
|
61
|
-
get: async () => stored,
|
|
62
|
-
updateStatus: async (
|
|
63
|
-
_configId: string,
|
|
64
|
-
_id: string,
|
|
65
|
-
status: OutboxMessageItem["status"],
|
|
66
|
-
) => {
|
|
67
|
-
harness.statusWrites.push(status);
|
|
68
|
-
return { ...stored, status };
|
|
69
|
-
},
|
|
70
|
-
updateIfStatus: async (
|
|
71
|
-
_configId: string,
|
|
72
|
-
_id: string,
|
|
73
|
-
_expected: OutboxMessageItem["status"],
|
|
74
|
-
input: { status?: OutboxMessageItem["status"] },
|
|
75
|
-
) => {
|
|
76
|
-
if (input.status) harness.statusWrites.push(input.status);
|
|
77
|
-
return { ...stored, ...input };
|
|
78
|
-
},
|
|
79
|
-
delete: async (_configId: string, id: string) => {
|
|
80
|
-
harness.deleted.push(id);
|
|
81
|
-
},
|
|
82
|
-
} as unknown as IOutboxMessageRepository;
|
|
83
|
-
|
|
84
|
-
harness.service = new OutboxQueueService({
|
|
85
|
-
outboxMessageService,
|
|
86
|
-
outboxAttachmentService: {
|
|
87
|
-
discardAll: async (_configId: string, _accountId: string, id: string) => {
|
|
88
|
-
harness.discarded.push(id);
|
|
89
|
-
},
|
|
90
|
-
unfinishedUpload: async () => undefined,
|
|
91
|
-
} as unknown as OutboxAttachmentService,
|
|
92
|
-
accountService: {
|
|
93
|
-
get: async () => ({ syncedServices: ["Mail"] }),
|
|
94
|
-
} as unknown as IAccountRepository,
|
|
95
|
-
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
96
|
-
sqsClient: {
|
|
97
|
-
send: async (command: { input: { MessageBody: string } }) => {
|
|
98
|
-
harness.enqueued.push(command.input.MessageBody);
|
|
99
|
-
return {};
|
|
100
|
-
},
|
|
101
|
-
} as never,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
return harness;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
describe("a row the worker settled at `failed`", () => {
|
|
108
|
-
it("is sendable again — Retry is the whole point of settling there", async () => {
|
|
109
|
-
const harness = createHarness(row(OutboxMessageStatus.failed));
|
|
110
|
-
|
|
111
|
-
await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
112
|
-
|
|
113
|
-
assert.deepEqual(harness.statusWrites, [OutboxMessageStatus.queued]);
|
|
114
|
-
assert.equal(harness.enqueued.length, 1);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("is discardable — the other way out of the Outbox", async () => {
|
|
118
|
-
const harness = createHarness(row(OutboxMessageStatus.failed));
|
|
119
|
-
|
|
120
|
-
await harness.service.deleteDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
121
|
-
|
|
122
|
-
assert.deepEqual(harness.deleted, [OUTBOX_MESSAGE_ID]);
|
|
123
|
-
assert.deepEqual(harness.discarded, [OUTBOX_MESSAGE_ID]);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe("a row the worker settled at `unfiled`", () => {
|
|
128
|
-
it("is discardable but never sendable — the server may already hold it", async () => {
|
|
129
|
-
const harness = createHarness(row(OutboxMessageStatus.unfiled));
|
|
130
|
-
|
|
131
|
-
await assert.rejects(
|
|
132
|
-
() => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
133
|
-
/cannot be sent again/,
|
|
134
|
-
);
|
|
135
|
-
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
136
|
-
|
|
137
|
-
await harness.service.deleteDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
138
|
-
assert.deepEqual(harness.deleted, [OUTBOX_MESSAGE_ID]);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Runs the write set against fixed repositories with no surrounding
|
|
5
|
-
* transaction: the writes are not atomic. For a backend that has no
|
|
6
|
-
* cross-entity transaction this matches its own guarantees; the SQLite path
|
|
7
|
-
* 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
|
-
}
|