@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
package/src/ownership.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { AccountItem, MailboxItem } from "@remit/data-ports";
|
|
2
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
|
+
import {
|
|
4
|
+
type AccessMode,
|
|
5
|
+
accept,
|
|
6
|
+
invalid,
|
|
7
|
+
type Refusable,
|
|
8
|
+
refuse,
|
|
9
|
+
} from "./refusal.js";
|
|
10
|
+
|
|
11
|
+
// A read refuses with NotFound so another tenant's account does not leak its
|
|
12
|
+
// existence; an action names the resource already, so it is refused outright.
|
|
13
|
+
export const ownedAccount = <
|
|
14
|
+
A extends Pick<AccountItem, "accountId" | "accountConfigId">,
|
|
15
|
+
>(
|
|
16
|
+
account: A,
|
|
17
|
+
accountConfigId: string,
|
|
18
|
+
mode: AccessMode,
|
|
19
|
+
): Refusable<A> => {
|
|
20
|
+
if (account.accountConfigId === accountConfigId) return accept(account);
|
|
21
|
+
if (mode === "read") {
|
|
22
|
+
return refuse({
|
|
23
|
+
kind: "NotFound",
|
|
24
|
+
message: `Account not found: ${account.accountId}`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return refuse({
|
|
28
|
+
kind: "Forbidden",
|
|
29
|
+
message: `Account ${account.accountId} not in account config`,
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Owning the account in a path proves nothing about the mailboxId beside it.
|
|
34
|
+
export const folderInAccount = <
|
|
35
|
+
M extends Pick<MailboxItem, "mailboxId" | "accountId">,
|
|
36
|
+
>(
|
|
37
|
+
mailbox: M,
|
|
38
|
+
accountId: string,
|
|
39
|
+
mode: AccessMode,
|
|
40
|
+
): Refusable<M> => {
|
|
41
|
+
if (mailbox.accountId === accountId) return accept(mailbox);
|
|
42
|
+
if (mode === "read") {
|
|
43
|
+
return refuse({
|
|
44
|
+
kind: "NotFound",
|
|
45
|
+
message: `Mailbox not found: ${mailbox.mailboxId}`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return refuse({
|
|
49
|
+
kind: "Forbidden",
|
|
50
|
+
message: `Mailbox ${mailbox.mailboxId} not in account`,
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const UNSETTLED_REASON: Partial<
|
|
55
|
+
Record<MailboxItem["syncStatus"], (path: string) => string>
|
|
56
|
+
> = {
|
|
57
|
+
[MailboxSyncStatus.pending]: (path) =>
|
|
58
|
+
`“${path}” isn’t ready yet — the mail server hasn’t confirmed it.`,
|
|
59
|
+
[MailboxSyncStatus.deleting]: (path) => `“${path}” is being deleted.`,
|
|
60
|
+
[MailboxSyncStatus.failed]: (path) =>
|
|
61
|
+
`The last change to “${path}” failed. Retry or dismiss it first.`,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// A durable reference binds only to a folder the mail server has confirmed
|
|
65
|
+
// (imap-mutations R2: wait). This is the server-side floor under the client's
|
|
66
|
+
// own wait, because a script calling the API directly gets no wait at all.
|
|
67
|
+
export const settledFolder = <
|
|
68
|
+
M extends Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
|
|
69
|
+
>(
|
|
70
|
+
target: M,
|
|
71
|
+
): Refusable<M> => {
|
|
72
|
+
const reason = UNSETTLED_REASON[target.syncStatus];
|
|
73
|
+
if (!reason) return accept(target);
|
|
74
|
+
return refuse({
|
|
75
|
+
kind: "NotSettled",
|
|
76
|
+
message: reason(target.fullPath),
|
|
77
|
+
mailboxId: target.mailboxId,
|
|
78
|
+
syncStatus: target.syncStatus,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface MessageOwnershipClient {
|
|
83
|
+
message: { get(messageId: string): Promise<{ mailboxId: string }> };
|
|
84
|
+
mailbox: {
|
|
85
|
+
get(
|
|
86
|
+
accountId: string,
|
|
87
|
+
mailboxIds: string[],
|
|
88
|
+
): Promise<{ mailboxId: string; accountId: string }[]>;
|
|
89
|
+
};
|
|
90
|
+
account: {
|
|
91
|
+
listAllByAccountConfig(
|
|
92
|
+
accountConfigId: string,
|
|
93
|
+
): Promise<{ accountId: string }[]>;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Messages carry no tenant, so ownership resolves through the caller's own
|
|
98
|
+
// accounts: a mailbox resolves only under the account that owns it, and the
|
|
99
|
+
// whole batch must land on one account because the downstream services take
|
|
100
|
+
// a single accountId.
|
|
101
|
+
export const ownedMessages = async (
|
|
102
|
+
client: MessageOwnershipClient,
|
|
103
|
+
messageIds: string[],
|
|
104
|
+
callerAccountConfigId: string,
|
|
105
|
+
mode: AccessMode,
|
|
106
|
+
): Promise<Refusable<string>> => {
|
|
107
|
+
const messages = await Promise.all(
|
|
108
|
+
messageIds.map((id) => client.message.get(id)),
|
|
109
|
+
);
|
|
110
|
+
const mailboxIds = [...new Set(messages.map((m) => m.mailboxId))];
|
|
111
|
+
|
|
112
|
+
const ownedAccounts = await client.account.listAllByAccountConfig(
|
|
113
|
+
callerAccountConfigId,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const accountByMailbox = new Map<string, string>();
|
|
117
|
+
for (const { accountId } of ownedAccounts) {
|
|
118
|
+
const rows = await client.mailbox.get(accountId, mailboxIds);
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
accountByMailbox.set(row.mailboxId, row.accountId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (mailboxIds.some((id) => !accountByMailbox.has(id))) {
|
|
125
|
+
if (mode === "read") {
|
|
126
|
+
return refuse({ kind: "NotFound", message: "Message not found" });
|
|
127
|
+
}
|
|
128
|
+
return refuse({
|
|
129
|
+
kind: "Forbidden",
|
|
130
|
+
message: "Message not in account config",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const accountIds = [...new Set(accountByMailbox.values())];
|
|
135
|
+
const [accountId] = accountIds;
|
|
136
|
+
if (accountIds.length !== 1 || accountId === undefined) {
|
|
137
|
+
return invalid("All messages must belong to the same account");
|
|
138
|
+
}
|
|
139
|
+
return accept(accountId);
|
|
140
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IMessageRepository, MessageItem } from "@remit/data-ports";
|
|
1
2
|
import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
|
|
2
3
|
import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
3
4
|
import type { PlacementMoveLogger } from "./placement-move.js";
|
|
@@ -5,22 +6,19 @@ import {
|
|
|
5
6
|
type RestoreSourcePlacementDeps,
|
|
6
7
|
restoreSourcePlacement,
|
|
7
8
|
} from "./restore-source-placement.js";
|
|
8
|
-
import {
|
|
9
|
-
reconcileStaleMessage,
|
|
10
|
-
type StaleMessageReconcileDeps,
|
|
11
|
-
} from "./stale-message-reconcile.js";
|
|
12
9
|
import type { IImapConnection } from "./types.js";
|
|
13
10
|
|
|
14
11
|
export interface ResolveExhaustedPlacementMoveDeps {
|
|
15
|
-
messageService:
|
|
12
|
+
messageService: Pick<IMessageRepository, "deleteIfPlacement"> &
|
|
16
13
|
RestoreSourcePlacementDeps["messageService"];
|
|
17
|
-
threadMessageService:
|
|
18
|
-
RestoreSourcePlacementDeps["threadMessageService"];
|
|
14
|
+
threadMessageService: RestoreSourcePlacementDeps["threadMessageService"];
|
|
19
15
|
markerService: { delete(messageId: string): Promise<void> };
|
|
20
16
|
log: PlacementMoveLogger;
|
|
21
17
|
}
|
|
22
18
|
|
|
23
19
|
export interface ResolveExhaustedPlacementMoveInput {
|
|
20
|
+
/** Where the row stood when the caller read it; the delete holds only while it still does. */
|
|
21
|
+
placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
|
|
24
22
|
accountId: string;
|
|
25
23
|
accountConfigId: string;
|
|
26
24
|
messageId: string;
|
|
@@ -47,7 +45,7 @@ export interface ResolveExhaustedPlacementMoveResult {
|
|
|
47
45
|
* than by a FETCH coming back empty. Per invariant 2, an external delete
|
|
48
46
|
* supersedes the marker entirely: the marker is dropped and the stale
|
|
49
47
|
* Message/ThreadMessage rows are deleted via
|
|
50
|
-
*
|
|
48
|
+
* `deleteIfPlacement`. This is also the
|
|
51
49
|
* outcome for the (rarer, functionally indistinguishable from here) case
|
|
52
50
|
* where a foreign client moved the message elsewhere — either way, our
|
|
53
51
|
* prediction no longer holds, and the marker cannot be honoured. Metric
|
|
@@ -94,10 +92,9 @@ export const resolveExhaustedPlacementMoveFailure = async (
|
|
|
94
92
|
|
|
95
93
|
if (await isMessageGoneFromOpenMailbox(connection, uid)) {
|
|
96
94
|
await deps.markerService.delete(messageId);
|
|
97
|
-
const
|
|
98
|
-
deps,
|
|
99
|
-
accountConfigId,
|
|
95
|
+
const reconciled = await deps.messageService.deleteIfPlacement(
|
|
100
96
|
messageId,
|
|
97
|
+
input.placement,
|
|
101
98
|
);
|
|
102
99
|
deps.log.info(
|
|
103
100
|
{
|
|
@@ -107,7 +104,7 @@ export const resolveExhaustedPlacementMoveFailure = async (
|
|
|
107
104
|
messageId,
|
|
108
105
|
uid,
|
|
109
106
|
sourceMailboxPath,
|
|
110
|
-
|
|
107
|
+
reconciled,
|
|
111
108
|
},
|
|
112
109
|
"Message no longer at its pending-move source after retry exhaustion (external delete or move); marker dropped, stale row reconciled",
|
|
113
110
|
);
|
package/src/placement-move.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
|
|
3
3
|
import type {
|
|
4
4
|
IAddressRepository,
|
|
5
|
+
IMailboxRepository,
|
|
5
6
|
IMailboxSpecialUseRepository,
|
|
6
7
|
IMessagePlacementMoveRepository,
|
|
7
8
|
IMessageRepository,
|
|
@@ -34,6 +35,9 @@ export interface PlacementMovePushEvent {
|
|
|
34
35
|
messageId: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Whether a filing happened. `refused` names a destination outside the message's account. */
|
|
39
|
+
export type PlacementMoveOutcome = "filed" | "skipped" | "refused";
|
|
40
|
+
|
|
37
41
|
export interface PlacementMoveLogger {
|
|
38
42
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
39
43
|
error(obj: Record<string, unknown>, msg: string): void;
|
|
@@ -55,6 +59,7 @@ export interface PlacementMoveConfig {
|
|
|
55
59
|
* every auto-filed spam suggestible in the compose picker.
|
|
56
60
|
*/
|
|
57
61
|
mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
62
|
+
mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
|
|
58
63
|
sqsQueueUrl: string;
|
|
59
64
|
sqsEndpoint?: string;
|
|
60
65
|
logger?: PlacementMoveLogger;
|
|
@@ -99,6 +104,7 @@ export class PlacementMoveService {
|
|
|
99
104
|
private markerService: IMessagePlacementMoveRepository;
|
|
100
105
|
private addressService: IAddressRepository;
|
|
101
106
|
private mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
107
|
+
private mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
|
|
102
108
|
private sqs: SQSClient;
|
|
103
109
|
private queueUrl: string;
|
|
104
110
|
private log: PlacementMoveLogger;
|
|
@@ -111,6 +117,7 @@ export class PlacementMoveService {
|
|
|
111
117
|
this.markerService = config.markerService;
|
|
112
118
|
this.addressService = config.addressService;
|
|
113
119
|
this.mailboxSpecialUseService = config.mailboxSpecialUseService;
|
|
120
|
+
this.mailboxService = config.mailboxService;
|
|
114
121
|
this.queueUrl = config.sqsQueueUrl;
|
|
115
122
|
this.log = config.logger ?? noopLogger;
|
|
116
123
|
this.moveSettleTimeoutMs =
|
|
@@ -128,7 +135,7 @@ export class PlacementMoveService {
|
|
|
128
135
|
messageId: string,
|
|
129
136
|
destinationMailboxId: string,
|
|
130
137
|
accountId: string,
|
|
131
|
-
): Promise<
|
|
138
|
+
): Promise<PlacementMoveOutcome> => {
|
|
132
139
|
let message = await this.messageService.get(messageId);
|
|
133
140
|
|
|
134
141
|
// Recovery: a marker already exists for THIS message and destination —
|
|
@@ -156,7 +163,7 @@ export class PlacementMoveService {
|
|
|
156
163
|
// queued / processing / processed already has its own driver in
|
|
157
164
|
// flight (the SQS message, or the reconciler currently running) —
|
|
158
165
|
// nothing to do here either way.
|
|
159
|
-
return;
|
|
166
|
+
return "filed";
|
|
160
167
|
}
|
|
161
168
|
|
|
162
169
|
// A different destination, decided while an earlier move is still
|
|
@@ -176,7 +183,7 @@ export class PlacementMoveService {
|
|
|
176
183
|
{ messageId, accountId, destinationMailboxId },
|
|
177
184
|
"Placement move not applied: this message is being deleted",
|
|
178
185
|
);
|
|
179
|
-
return;
|
|
186
|
+
return "skipped";
|
|
180
187
|
}
|
|
181
188
|
|
|
182
189
|
if (isPlacementUnsettled(message)) {
|
|
@@ -189,7 +196,7 @@ export class PlacementMoveService {
|
|
|
189
196
|
{ messageId, accountId, destinationMailboxId },
|
|
190
197
|
"Placement move not applied: a delete claimed this message while the earlier move settled",
|
|
191
198
|
);
|
|
192
|
-
return;
|
|
199
|
+
return "skipped";
|
|
193
200
|
}
|
|
194
201
|
if (isPlacementUnsettled(message)) {
|
|
195
202
|
throw new Error(
|
|
@@ -203,7 +210,26 @@ export class PlacementMoveService {
|
|
|
203
210
|
// Genuine no-op: nothing pending for this destination, and the message
|
|
204
211
|
// is already there (a duplicate verdict recomputed after a confirmed
|
|
205
212
|
// move).
|
|
206
|
-
if (sourceMailboxId === destinationMailboxId) return;
|
|
213
|
+
if (sourceMailboxId === destinationMailboxId) return "skipped";
|
|
214
|
+
|
|
215
|
+
// IMAP has no MOVE between accounts, so a filing into a folder this
|
|
216
|
+
// account does not own would only ever be handed back.
|
|
217
|
+
if (
|
|
218
|
+
(await this.mailboxService.resolveAccountId(destinationMailboxId)) !==
|
|
219
|
+
accountId
|
|
220
|
+
) {
|
|
221
|
+
this.log.error(
|
|
222
|
+
{
|
|
223
|
+
alert: "placement_move_foreign_folder",
|
|
224
|
+
messageId,
|
|
225
|
+
accountId,
|
|
226
|
+
sourceMailboxId,
|
|
227
|
+
destinationMailboxId,
|
|
228
|
+
},
|
|
229
|
+
"Placement move refused: the destination is not a folder of this message's account",
|
|
230
|
+
);
|
|
231
|
+
return "refused";
|
|
232
|
+
}
|
|
207
233
|
|
|
208
234
|
// Fresh move. Strictly sequential — nothing here is parallel: the local
|
|
209
235
|
// move (marker + ThreadMessage + Message row) fully commits BEFORE the
|
|
@@ -256,7 +282,7 @@ export class PlacementMoveService {
|
|
|
256
282
|
{ messageId, accountId, sourceMailboxId, destinationMailboxId },
|
|
257
283
|
"Placement move not applied: something else claimed this message's placement first",
|
|
258
284
|
);
|
|
259
|
-
return;
|
|
285
|
+
return "skipped";
|
|
260
286
|
}
|
|
261
287
|
|
|
262
288
|
await this.updateThreadMessageMailbox(
|
|
@@ -286,6 +312,7 @@ export class PlacementMoveService {
|
|
|
286
312
|
{ messageId, accountId, sourceMailboxId, destinationMailboxId },
|
|
287
313
|
"Placement move applied locally; pending marker queued for IMAP push",
|
|
288
314
|
);
|
|
315
|
+
return "filed";
|
|
289
316
|
};
|
|
290
317
|
|
|
291
318
|
private updateThreadMessageMailbox = async (
|
package/src/placement-settled.ts
CHANGED
|
@@ -46,17 +46,32 @@ export const isPlacementUnsettled = (
|
|
|
46
46
|
* untouched, so anything that resolves a uid off a row it did not settle
|
|
47
47
|
* itself asks this rather than the binding below (issue #1217).
|
|
48
48
|
*/
|
|
49
|
-
export const carriesForeignUid =
|
|
50
|
-
|
|
49
|
+
export const carriesForeignUid = <
|
|
50
|
+
T extends Pick<
|
|
51
51
|
MessageItem,
|
|
52
52
|
"mailboxId" | "uid" | "originalMailboxId" | "originalUid"
|
|
53
53
|
>,
|
|
54
|
-
|
|
54
|
+
>(
|
|
55
|
+
message: T,
|
|
56
|
+
): message is T & { originalMailboxId: string; originalUid: number } =>
|
|
55
57
|
message.originalUid !== undefined &&
|
|
56
58
|
message.originalUid === message.uid &&
|
|
57
59
|
message.originalMailboxId !== undefined &&
|
|
58
60
|
message.originalMailboxId !== message.mailboxId;
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The folder the row's `uid` is a uid in: the source while a move has not
|
|
64
|
+
* confirmed, otherwise the folder the row is in. A fetch by uid is only safe
|
|
65
|
+
* from this folder; from any other the same number names a different message.
|
|
66
|
+
*/
|
|
67
|
+
export const uidMailboxIdOf = (
|
|
68
|
+
message: Pick<
|
|
69
|
+
MessageItem,
|
|
70
|
+
"mailboxId" | "uid" | "originalMailboxId" | "originalUid"
|
|
71
|
+
>,
|
|
72
|
+
): string =>
|
|
73
|
+
carriesForeignUid(message) ? message.originalMailboxId : message.mailboxId;
|
|
74
|
+
|
|
60
75
|
export const bindsForeignUid = (
|
|
61
76
|
message: Pick<
|
|
62
77
|
MessageItem,
|
|
@@ -64,12 +79,18 @@ export const bindsForeignUid = (
|
|
|
64
79
|
>,
|
|
65
80
|
): boolean => isPlacementUnsettled(message) && carriesForeignUid(message);
|
|
66
81
|
|
|
82
|
+
/** A copy at `uid: 0`, which names nothing on the server until COPYUID lands (#1387). */
|
|
83
|
+
const awaitsCopiedUid = (
|
|
84
|
+
message: Pick<MessageItem, "status" | "uid">,
|
|
85
|
+
): boolean => message.status === MessageStatus.moving && message.uid === 0;
|
|
86
|
+
|
|
67
87
|
/**
|
|
68
88
|
* `in_flight` — a mutation is still working on the row, so waiting resolves it.
|
|
69
89
|
* That includes a row mid-retry: `message-move`, `message-delete` and
|
|
70
90
|
* `message-copy` write `syncStatus: failed` on an ordinary transient attempt
|
|
71
91
|
* and re-throw for redelivery, so a failed attempt is a mutation about to
|
|
72
92
|
* succeed and the settle ceiling is exactly what it is for (imap-mutations R3).
|
|
93
|
+
* It also includes a fresh copy, whose uid names nothing until COPYUID lands.
|
|
73
94
|
*
|
|
74
95
|
* There is no give-up member, because a give-up cannot reach here: `abandoned`
|
|
75
96
|
* is written only alongside `status: active` (a hand-back) or `deleted` (a copy
|
|
@@ -85,7 +106,10 @@ export const placementBindingOf = (
|
|
|
85
106
|
MessageItem,
|
|
86
107
|
"status" | "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
|
|
87
108
|
>,
|
|
88
|
-
): PlacementBinding =>
|
|
109
|
+
): PlacementBinding =>
|
|
110
|
+
bindsForeignUid(message) || awaitsCopiedUid(message)
|
|
111
|
+
? "in_flight"
|
|
112
|
+
: "consistent";
|
|
89
113
|
|
|
90
114
|
export interface PlacementSettleOptions {
|
|
91
115
|
timeoutMs: number;
|