@remit/mailbox-service 0.0.90 → 0.0.92
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 +6 -1
- package/src/filters/filter-config.test.ts +229 -0
- package/src/filters/filter-config.ts +69 -0
- package/src/filters/message-embedder.test.ts +39 -0
- package/src/filters/message-embedder.ts +33 -0
- package/src/filters/pipeline.ts +28 -1
- package/src/index.ts +13 -0
- package/src/message-delete-junk-reconcile.test.ts +5 -2
- package/src/message-delete-no-trash.test.ts +5 -1
- package/src/message-delete-unsettled-placement.test.ts +25 -17
- package/src/message-move-copy.test.ts +28 -1
- package/src/message-move-empty-trash.test.ts +48 -1
- package/src/message-move-same-mailbox.test.ts +5 -2
- package/src/message-move-unsettled-placement.test.ts +56 -17
- package/src/message-move.ts +189 -62
- package/src/message-sync-repoint.test.ts +22 -2
- package/src/message-sync.ts +9 -10
- package/src/placement-move-terminal.test.ts +198 -133
- package/src/placement-move-terminal.ts +47 -14
- package/src/placement-move-unsettled.test.ts +18 -4
- package/src/placement-move.ts +65 -9
- package/src/placement-settled.test.ts +36 -6
- package/src/placement-settled.ts +28 -25
- package/src/placement-transition-race.test.ts +482 -0
- package/src/restore-source-placement.ts +157 -0
- package/src/spam-report.test.ts +13 -10
- package/src/spam-report.ts +28 -2
- package/src/thread-message-rows.ts +77 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IMessageRepository,
|
|
3
|
+
IThreadMessageRepository,
|
|
4
|
+
MessageItem,
|
|
5
|
+
ThreadMessageItem,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
8
|
+
import { MessageStatus } from "@remit/domain-enums";
|
|
9
|
+
import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
|
|
10
|
+
|
|
11
|
+
export interface RestoreSourcePlacementDeps {
|
|
12
|
+
messageService: Pick<IMessageRepository, "transitionPlacement">;
|
|
13
|
+
threadMessageService: Pick<
|
|
14
|
+
IThreadMessageRepository,
|
|
15
|
+
"findAllByMessageId" | "update"
|
|
16
|
+
>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Whether the row was still owed a restore when this ran. */
|
|
20
|
+
export type RestoreSourcePlacementOutcome = "restored" | "superseded";
|
|
21
|
+
|
|
22
|
+
export interface RestoreSourcePlacementInput {
|
|
23
|
+
accountConfigId: string;
|
|
24
|
+
messageId: string;
|
|
25
|
+
sourceMailboxId: string;
|
|
26
|
+
uid: number;
|
|
27
|
+
/**
|
|
28
|
+
* What the row records about the mutation that will now never happen.
|
|
29
|
+
* `synced` where the row is a faithful projection of the source again and
|
|
30
|
+
* nothing was refused. `abandoned` where a mutation the user asked for gave
|
|
31
|
+
* up, which is a failure the row has to carry (imap-mutations R3).
|
|
32
|
+
*/
|
|
33
|
+
syncStatus: MessageItem["syncStatus"];
|
|
34
|
+
/**
|
|
35
|
+
* Which mutation gave up, for the row to carry alongside `abandoned`. The
|
|
36
|
+
* hand-back is about to set `status` back to `active`, and `status` was the
|
|
37
|
+
* only field naming it — without this the client can see that something was
|
|
38
|
+
* abandoned and not what, which is how a move that handed back was reported
|
|
39
|
+
* as a failed delete (issue #1229). `none` accompanies every other
|
|
40
|
+
* `syncStatus`, so the field never outlives its gate.
|
|
41
|
+
*/
|
|
42
|
+
abandonedMutation: MessageItem["abandonedMutation"];
|
|
43
|
+
/**
|
|
44
|
+
* The listing rows, where the caller has already read them to pick this
|
|
45
|
+
* outcome. Omitted, they are read here.
|
|
46
|
+
*/
|
|
47
|
+
threadMessages?: ThreadMessageItem[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Put a row back on the pre-mutation source pair and hand its listing rows back
|
|
52
|
+
* with it.
|
|
53
|
+
*
|
|
54
|
+
* `status: moving` has to be cleared here or the row is stuck: a give-up that
|
|
55
|
+
* writes a sync status and returns leaves the row naming the destination with
|
|
56
|
+
* the source's uid — the pair `placementBindingOf` calls a lie and
|
|
57
|
+
* `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs it
|
|
58
|
+
* (sync does not touch `status`, and the cursor rebuild's `updateUid` runs only
|
|
59
|
+
* on a UIDVALIDITY change), so the message is undeletable and unmovable for good
|
|
60
|
+
* (issue #1005).
|
|
61
|
+
*
|
|
62
|
+
* A transition, not a plain update (imap-mutations R3): the restore is only
|
|
63
|
+
* owed while a mutation is still outstanding on the row, so the predicate is
|
|
64
|
+
* `moving` or `deleting`. A row another lane has already settled — the
|
|
65
|
+
* placement-move push runs off a standard queue and is ordered against nothing
|
|
66
|
+
* on the account's FIFO — is the winner, and dragging it back to a source it
|
|
67
|
+
* has since left is the corruption this predicate exists to refuse. The loser
|
|
68
|
+
* writes nothing at all, listing rows included, and answers `superseded`.
|
|
69
|
+
*
|
|
70
|
+
* No `message.moved` outbox row: nothing here moved. The optimistic write that
|
|
71
|
+
* pointed the row at the destination never enqueued one either, so the search
|
|
72
|
+
* index still holds the source and a revert has nothing to correct.
|
|
73
|
+
*
|
|
74
|
+
* The caller must have evidence, not an inference: this writes a placement, and
|
|
75
|
+
* a placement written on a guess binds live mail to a uid that names somebody
|
|
76
|
+
* else's message. Two kinds of caller qualify, and nothing else does.
|
|
77
|
+
*
|
|
78
|
+
* A terminal resolver reaches here from {@link isMessageGoneFromOpenMailbox}
|
|
79
|
+
* answering that the message is STILL at `sourceMailboxId`/`uid`, which makes
|
|
80
|
+
* this the server's own answer rather than a revert on ambiguity (what PR #652
|
|
81
|
+
* was pulled for).
|
|
82
|
+
*
|
|
83
|
+
* A paused-cursor caller reaches here from a `MailboxCursorPausedError` the
|
|
84
|
+
* openBox guard threw before the outbound command was issued (issue #1203), and
|
|
85
|
+
* restores the pre-mutation pair off its own event. On a FIRST delivery that is
|
|
86
|
+
* not an inference but an undo: the command provably never left, so the row is
|
|
87
|
+
* put back exactly as it was found. A redelivery cannot claim that — the
|
|
88
|
+
* earlier attempt's tagged OK can be lost with the connection — so it asks
|
|
89
|
+
* `probePausedPlacement` which folder holds the message and only reaches here
|
|
90
|
+
* once the answer rules the destination out. Either way the source mailbox is
|
|
91
|
+
* by definition awaiting a cursor rebuild, which matches its rows by Message-ID
|
|
92
|
+
* and adjudicates the uid: handing the row back is what puts it in the set the
|
|
93
|
+
* rebuild walks, since a row still naming the destination is in neither
|
|
94
|
+
* folder's.
|
|
95
|
+
*
|
|
96
|
+
* A row another path deleted while the probe was in flight has nothing left to
|
|
97
|
+
* restore, and it fails the predicate like any other row that is no longer
|
|
98
|
+
* owed one. A listing row that other path has already rewritten is its to
|
|
99
|
+
* finish; the update for it comes back `NotFoundError`, and that is a settled
|
|
100
|
+
* state here rather than a fault. Throwing on it would escape the caller's
|
|
101
|
+
* `.catch()` and cost the record its ack and its resync, which is the contract
|
|
102
|
+
* the terminal resolvers state: never re-thrown.
|
|
103
|
+
*/
|
|
104
|
+
export const restoreSourcePlacement = async (
|
|
105
|
+
deps: RestoreSourcePlacementDeps,
|
|
106
|
+
input: RestoreSourcePlacementInput,
|
|
107
|
+
): Promise<RestoreSourcePlacementOutcome> => {
|
|
108
|
+
const {
|
|
109
|
+
accountConfigId,
|
|
110
|
+
messageId,
|
|
111
|
+
sourceMailboxId,
|
|
112
|
+
uid,
|
|
113
|
+
syncStatus,
|
|
114
|
+
abandonedMutation,
|
|
115
|
+
} = input;
|
|
116
|
+
|
|
117
|
+
const skipNotFound = (error: unknown): void => {
|
|
118
|
+
if (isNotFoundError(error)) return;
|
|
119
|
+
throw error;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const restored = await deps.messageService.transitionPlacement(
|
|
123
|
+
messageId,
|
|
124
|
+
{ status: [MessageStatus.moving, MessageStatus.deleting] },
|
|
125
|
+
{
|
|
126
|
+
mailboxId: sourceMailboxId,
|
|
127
|
+
uid,
|
|
128
|
+
status: MessageStatus.active,
|
|
129
|
+
syncStatus,
|
|
130
|
+
abandonedMutation,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
if (!restored) return "superseded";
|
|
134
|
+
|
|
135
|
+
const threadMessages =
|
|
136
|
+
input.threadMessages ??
|
|
137
|
+
(await deps.threadMessageService.findAllByMessageId(
|
|
138
|
+
accountConfigId,
|
|
139
|
+
messageId,
|
|
140
|
+
));
|
|
141
|
+
for (const threadMessage of threadMessages) {
|
|
142
|
+
const args = buildThreadMessageMoveRevert(
|
|
143
|
+
threadMessage,
|
|
144
|
+
uid,
|
|
145
|
+
sourceMailboxId,
|
|
146
|
+
);
|
|
147
|
+
await deps.threadMessageService
|
|
148
|
+
.update(
|
|
149
|
+
threadMessage.accountConfigId,
|
|
150
|
+
threadMessage.threadMessageId,
|
|
151
|
+
args.set,
|
|
152
|
+
{ composites: args.composites },
|
|
153
|
+
)
|
|
154
|
+
.catch(skipNotFound);
|
|
155
|
+
}
|
|
156
|
+
return "restored";
|
|
157
|
+
};
|
package/src/spam-report.test.ts
CHANGED
|
@@ -174,9 +174,20 @@ const buildWorld = (
|
|
|
174
174
|
if (m) Object.assign(m, patch);
|
|
175
175
|
return m;
|
|
176
176
|
},
|
|
177
|
-
|
|
177
|
+
transitionPlacement: async (
|
|
178
|
+
id: string,
|
|
179
|
+
_expected: Record<string, unknown>,
|
|
180
|
+
patch: Record<string, unknown>,
|
|
181
|
+
) => {
|
|
178
182
|
const m = messages.get(id);
|
|
179
|
-
if (m)
|
|
183
|
+
if (!m) return undefined;
|
|
184
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
185
|
+
if (value === null) {
|
|
186
|
+
delete m[field as keyof typeof m];
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
Object.assign(m, { [field]: value });
|
|
190
|
+
}
|
|
180
191
|
return m;
|
|
181
192
|
},
|
|
182
193
|
clearSpamReport: async (id: string) => {
|
|
@@ -184,14 +195,6 @@ const buildWorld = (
|
|
|
184
195
|
if (m) delete m.spamReport;
|
|
185
196
|
return m;
|
|
186
197
|
},
|
|
187
|
-
clearOriginalMailboxId: async (id: string) => {
|
|
188
|
-
const m = messages.get(id);
|
|
189
|
-
if (m) {
|
|
190
|
-
delete m.originalMailboxId;
|
|
191
|
-
delete m.originalUid;
|
|
192
|
-
}
|
|
193
|
-
return m;
|
|
194
|
-
},
|
|
195
198
|
} as unknown as IMessageRepository;
|
|
196
199
|
|
|
197
200
|
const addressService = {
|
package/src/spam-report.ts
CHANGED
|
@@ -239,7 +239,19 @@ export class SpamReportService {
|
|
|
239
239
|
// originalMailboxId, so a stale value left by some earlier, unrelated
|
|
240
240
|
// move would otherwise survive and send a later notSpam to the wrong
|
|
241
241
|
// folder. This report-spam action established no move of its own.
|
|
242
|
-
|
|
242
|
+
//
|
|
243
|
+
// A transition off the row this call read (imap-mutations R3): the pair
|
|
244
|
+
// being cleared belongs to a placement, and a lane that has claimed the
|
|
245
|
+
// row since keeps its own — its settle manages the pair.
|
|
246
|
+
await this.messageService.transitionPlacement(
|
|
247
|
+
messageId,
|
|
248
|
+
{
|
|
249
|
+
status: before.status,
|
|
250
|
+
mailboxId: before.mailboxId,
|
|
251
|
+
uid: before.uid,
|
|
252
|
+
},
|
|
253
|
+
{ originalMailboxId: null, originalUid: null },
|
|
254
|
+
);
|
|
243
255
|
}
|
|
244
256
|
|
|
245
257
|
await this.flagPushService.flip({
|
|
@@ -299,7 +311,21 @@ export class SpamReportService {
|
|
|
299
311
|
messageId,
|
|
300
312
|
accountId,
|
|
301
313
|
);
|
|
302
|
-
|
|
314
|
+
|
|
315
|
+
// Re-read, then clear against what was read. The restore just wrote a
|
|
316
|
+
// placement, so the row here is not the one above; predicating on it is
|
|
317
|
+
// what keeps this from erasing a pair some other lane is relying on
|
|
318
|
+
// (imap-mutations R3).
|
|
319
|
+
const restored = await this.messageService.get(messageId);
|
|
320
|
+
await this.messageService.transitionPlacement(
|
|
321
|
+
messageId,
|
|
322
|
+
{
|
|
323
|
+
status: restored.status,
|
|
324
|
+
mailboxId: restored.mailboxId,
|
|
325
|
+
uid: restored.uid,
|
|
326
|
+
},
|
|
327
|
+
{ originalMailboxId: null, originalUid: null },
|
|
328
|
+
);
|
|
303
329
|
}
|
|
304
330
|
|
|
305
331
|
const current = await this.messageService.get(messageId);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the `set` and `composites` payload for the ThreadMessage update on a
|
|
5
|
+
* MESSAGE_DELETE move-to-trash.
|
|
6
|
+
*
|
|
7
|
+
* The CURRENT row state goes in `composites`; the NEW values go in `set`.
|
|
8
|
+
* ElectroDB uses `composites` to run the conditional check on the existing row
|
|
9
|
+
* AND to compute the previous sort-key values needed to recompute the new ones.
|
|
10
|
+
* Passing the NEW values in `composites` makes the conditional check fail with
|
|
11
|
+
* ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
|
|
12
|
+
* the caller silently drops the update. Same root cause as PR #186 fixed for
|
|
13
|
+
* `flag-queue.ts`.
|
|
14
|
+
*/
|
|
15
|
+
export type ThreadMessageRowState = Pick<
|
|
16
|
+
ThreadMessageItem,
|
|
17
|
+
| "sentDate"
|
|
18
|
+
| "mailboxId"
|
|
19
|
+
| "isRead"
|
|
20
|
+
| "isDeleted"
|
|
21
|
+
| "hasStars"
|
|
22
|
+
| "hasAttachment"
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
const currentComposites = (threadMessage: ThreadMessageRowState) => ({
|
|
26
|
+
sentDate: threadMessage.sentDate,
|
|
27
|
+
mailboxId: threadMessage.mailboxId,
|
|
28
|
+
isRead: threadMessage.isRead,
|
|
29
|
+
isDeleted: threadMessage.isDeleted,
|
|
30
|
+
hasStars: threadMessage.hasStars,
|
|
31
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const buildThreadMessageTrashUpdate = (
|
|
35
|
+
threadMessage: ThreadMessageRowState,
|
|
36
|
+
newUid: number,
|
|
37
|
+
destinationMailboxId: string,
|
|
38
|
+
) => ({
|
|
39
|
+
set: {
|
|
40
|
+
uid: newUid,
|
|
41
|
+
mailboxId: destinationMailboxId,
|
|
42
|
+
isDeleted: true,
|
|
43
|
+
},
|
|
44
|
+
composites: currentComposites(threadMessage),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The inverse payload: put the thread row back where the message still is on
|
|
49
|
+
* the server. The delete was recorded optimistically, so a delete that gives up
|
|
50
|
+
* with the message still at its source has to hand the row back rather than
|
|
51
|
+
* leave it claiming Trash — an invisible `failed` on a row the user cannot see
|
|
52
|
+
* is the shape of the incident this whole change is about.
|
|
53
|
+
*/
|
|
54
|
+
export const buildThreadMessageMoveRevert = (
|
|
55
|
+
threadMessage: ThreadMessageRowState,
|
|
56
|
+
sourceUid: number,
|
|
57
|
+
sourceMailboxId: string,
|
|
58
|
+
) => ({
|
|
59
|
+
set: {
|
|
60
|
+
uid: sourceUid,
|
|
61
|
+
mailboxId: sourceMailboxId,
|
|
62
|
+
isDeleted: false,
|
|
63
|
+
},
|
|
64
|
+
composites: currentComposites(threadMessage),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hand a row back after an abandoned expunge. The mail never left Trash, so
|
|
69
|
+
* only the deletion mark reverts — the uid and mailbox on the row are still
|
|
70
|
+
* where the server has it.
|
|
71
|
+
*/
|
|
72
|
+
export const buildThreadMessageUndelete = (
|
|
73
|
+
threadMessage: ThreadMessageRowState,
|
|
74
|
+
) => ({
|
|
75
|
+
set: { isDeleted: false },
|
|
76
|
+
composites: currentComposites(threadMessage),
|
|
77
|
+
});
|