@remit/mailbox-service 0.0.80 → 0.0.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/message-sync-repoint.test.ts +374 -0
- package/src/message-sync.ts +133 -4
package/package.json
CHANGED
|
@@ -0,0 +1,374 @@
|
|
|
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 { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
15
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
16
|
+
import {
|
|
17
|
+
type AccountFolderRoles,
|
|
18
|
+
MessageSyncService,
|
|
19
|
+
repointsOnSighting,
|
|
20
|
+
} from "./message-sync.js";
|
|
21
|
+
import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
22
|
+
import type { ImapEnvelope, ImapMessage } from "./types.js";
|
|
23
|
+
|
|
24
|
+
const stub = <T>(): T => ({}) as T;
|
|
25
|
+
|
|
26
|
+
const envelope: ImapEnvelope = {
|
|
27
|
+
date: new Date(0).toISOString(),
|
|
28
|
+
messageId: "<root@example.com>",
|
|
29
|
+
subject: "Subject",
|
|
30
|
+
from: [{ name: "Pharma Deals", mailbox: "sales", host: "pharma.example" }],
|
|
31
|
+
sender: [],
|
|
32
|
+
replyTo: [],
|
|
33
|
+
to: [{ name: "", mailbox: "victim", host: "ischen.nl" }],
|
|
34
|
+
cc: [],
|
|
35
|
+
bcc: [],
|
|
36
|
+
inReplyTo: "",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const mailboxAt = (fullPath: string, specialUse?: string[]): MailboxItem =>
|
|
40
|
+
({
|
|
41
|
+
mailboxId: `mbx-${fullPath}`,
|
|
42
|
+
fullPath,
|
|
43
|
+
hierarchyDelimiter: "/",
|
|
44
|
+
...(specialUse ? { specialUse } : {}),
|
|
45
|
+
}) as MailboxItem;
|
|
46
|
+
|
|
47
|
+
const INBOX = mailboxAt("INBOX");
|
|
48
|
+
const JUNK = mailboxAt("INBOX/Rubbish");
|
|
49
|
+
const TRASH = mailboxAt("Trash");
|
|
50
|
+
const ALL_MAIL = mailboxAt("[Gmail]/All Mail", ["All"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `pending` is what an ordinary inbound row carries: `upsertWithStatus` is
|
|
54
|
+
* called without a `syncStatus` and the repository defaults to it, and nothing
|
|
55
|
+
* on the sync path ever promotes it (#1096). The fixture states the reachable
|
|
56
|
+
* state, not the one the gate used to demand.
|
|
57
|
+
*/
|
|
58
|
+
const storedIn = (
|
|
59
|
+
mailbox: MailboxItem,
|
|
60
|
+
overrides: Partial<MessageItem> = {},
|
|
61
|
+
): MessageItem =>
|
|
62
|
+
({
|
|
63
|
+
mailboxId: mailbox.mailboxId,
|
|
64
|
+
uid: 7,
|
|
65
|
+
status: MessageStatus.active,
|
|
66
|
+
syncStatus: MessageSyncStatus.pending,
|
|
67
|
+
...overrides,
|
|
68
|
+
}) as MessageItem;
|
|
69
|
+
|
|
70
|
+
const threadRowIn = (mailbox: MailboxItem): ThreadMessageItem =>
|
|
71
|
+
({
|
|
72
|
+
threadMessageId: "tm-1",
|
|
73
|
+
accountConfigId: "cfg-1",
|
|
74
|
+
mailboxId: mailbox.mailboxId,
|
|
75
|
+
uid: 7,
|
|
76
|
+
sentDate: 0,
|
|
77
|
+
isRead: false,
|
|
78
|
+
isDeleted: mailbox === TRASH,
|
|
79
|
+
hasStars: false,
|
|
80
|
+
hasAttachment: false,
|
|
81
|
+
}) as ThreadMessageItem;
|
|
82
|
+
|
|
83
|
+
interface Observed {
|
|
84
|
+
repointedTo: Array<{ mailboxId: string; uid: number }>;
|
|
85
|
+
threadUpdates: UpdateThreadMessageInput[];
|
|
86
|
+
reconciled: string[];
|
|
87
|
+
owned: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sync one message out of `sighting`, against a database that already holds it
|
|
92
|
+
* under `stored`. The account's Junk folder is `JUNK` and its Trash is `TRASH`,
|
|
93
|
+
* so the sighting's own role follows from which mailbox it is.
|
|
94
|
+
*/
|
|
95
|
+
const sync = async (
|
|
96
|
+
sighting: MailboxItem,
|
|
97
|
+
stored: MessageItem,
|
|
98
|
+
threadRow: ThreadMessageItem | null = threadRowIn(INBOX),
|
|
99
|
+
copiesHere: MessageItem[] = [],
|
|
100
|
+
): Promise<Observed> => {
|
|
101
|
+
const observed: Observed = {
|
|
102
|
+
repointedTo: [],
|
|
103
|
+
threadUpdates: [],
|
|
104
|
+
reconciled: [],
|
|
105
|
+
owned: false,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const messageService = {
|
|
109
|
+
upsertWithStatus: async () => ({ item: stored, created: false }),
|
|
110
|
+
get: async () => copiesHere,
|
|
111
|
+
updateUid: async (_id: string, uid: number, mailboxId: string) => {
|
|
112
|
+
observed.repointedTo.push({ mailboxId, uid });
|
|
113
|
+
return stored;
|
|
114
|
+
},
|
|
115
|
+
} as unknown as IMessageRepository;
|
|
116
|
+
|
|
117
|
+
const threadMessageService = {
|
|
118
|
+
findByMessageId: async () => threadRow,
|
|
119
|
+
update: async (
|
|
120
|
+
_config: string,
|
|
121
|
+
_id: string,
|
|
122
|
+
input: UpdateThreadMessageInput,
|
|
123
|
+
) => {
|
|
124
|
+
observed.threadUpdates.push(input);
|
|
125
|
+
return threadRow as ThreadMessageItem;
|
|
126
|
+
},
|
|
127
|
+
create: async (input: unknown) => input as ThreadMessageItem,
|
|
128
|
+
} as unknown as IThreadMessageRepository;
|
|
129
|
+
|
|
130
|
+
const envelopeService = {
|
|
131
|
+
upsertEnvelope: async () => {},
|
|
132
|
+
upsertBodyParts: async () => {},
|
|
133
|
+
} as unknown as IEnvelopeRepository;
|
|
134
|
+
|
|
135
|
+
const addressService = {
|
|
136
|
+
upsertCorrespondentAddress: async () => {},
|
|
137
|
+
upsertJunkAddress: async () => {},
|
|
138
|
+
upsertAddress: async () => {},
|
|
139
|
+
upsertEnvelopeAddress: async () => {},
|
|
140
|
+
reconcileJunkOnlyForMessage: async (messageId: string) => {
|
|
141
|
+
observed.reconciled.push(messageId);
|
|
142
|
+
},
|
|
143
|
+
} as unknown as IAddressRepository;
|
|
144
|
+
|
|
145
|
+
const service = new MessageSyncService(
|
|
146
|
+
stub<ManagedConnectionFactory>(),
|
|
147
|
+
stub<IMailboxRepository>(),
|
|
148
|
+
folderRoles({
|
|
149
|
+
junkMailboxId: JUNK.mailboxId,
|
|
150
|
+
trashMailboxId: TRASH.mailboxId,
|
|
151
|
+
}),
|
|
152
|
+
messageService,
|
|
153
|
+
envelopeService,
|
|
154
|
+
addressService,
|
|
155
|
+
threadMessageService,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const msg = {
|
|
159
|
+
uid: 42,
|
|
160
|
+
seq: 1,
|
|
161
|
+
size: 100,
|
|
162
|
+
internalDate: new Date(0),
|
|
163
|
+
flags: [],
|
|
164
|
+
envelope,
|
|
165
|
+
} as unknown as ImapMessage;
|
|
166
|
+
|
|
167
|
+
const result = (await (
|
|
168
|
+
service as unknown as {
|
|
169
|
+
saveMessage: (
|
|
170
|
+
mailbox: MailboxItem,
|
|
171
|
+
accountId: string,
|
|
172
|
+
accountConfigId: string,
|
|
173
|
+
msg: ImapMessage,
|
|
174
|
+
roles: AccountFolderRoles,
|
|
175
|
+
) => Promise<{ owned: boolean }>;
|
|
176
|
+
}
|
|
177
|
+
).saveMessage(sighting, "acct-1", "cfg-1", msg, {
|
|
178
|
+
junkMailboxId: JUNK.mailboxId,
|
|
179
|
+
trashMailboxId: TRASH.mailboxId,
|
|
180
|
+
configJunkRoles: NO_JUNK_ROLES,
|
|
181
|
+
})) as { owned: boolean };
|
|
182
|
+
|
|
183
|
+
observed.owned = result.owned;
|
|
184
|
+
return observed;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
describe("which folder a message the database already holds lives in", () => {
|
|
188
|
+
it("follows the last move when another client files it into Junk", async () => {
|
|
189
|
+
const observed = await sync(JUNK, storedIn(INBOX));
|
|
190
|
+
|
|
191
|
+
assert.deepEqual(observed.repointedTo, [
|
|
192
|
+
{ mailboxId: JUNK.mailboxId, uid: 42 },
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("follows the last move back when another client rescues it", async () => {
|
|
197
|
+
const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
|
|
198
|
+
|
|
199
|
+
assert.deepEqual(observed.repointedTo, [
|
|
200
|
+
{ mailboxId: INBOX.mailboxId, uid: 42 },
|
|
201
|
+
]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("takes the UID of the folder that issued it, never the old one", async () => {
|
|
205
|
+
const observed = await sync(JUNK, storedIn(INBOX, { uid: 7 }));
|
|
206
|
+
|
|
207
|
+
assert.equal(observed.repointedTo[0].uid, 42);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("leaves a row this folder already owns alone", async () => {
|
|
211
|
+
const observed = await sync(INBOX, storedIn(INBOX));
|
|
212
|
+
|
|
213
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
214
|
+
assert.equal(observed.owned, true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("holds a row whose own move has not settled", async () => {
|
|
218
|
+
const observed = await sync(
|
|
219
|
+
INBOX,
|
|
220
|
+
storedIn(JUNK, {
|
|
221
|
+
status: MessageStatus.moving,
|
|
222
|
+
syncStatus: MessageSyncStatus.pending,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
227
|
+
assert.equal(observed.owned, false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("declines a sighting in a folder that copies every message", async () => {
|
|
231
|
+
const observed = await sync(ALL_MAIL, storedIn(INBOX));
|
|
232
|
+
|
|
233
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
234
|
+
assert.equal(observed.owned, false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("declines a sighting of a copy the user put in this folder", async () => {
|
|
238
|
+
const observed = await sync(JUNK, storedIn(INBOX), threadRowIn(INBOX), [
|
|
239
|
+
storedIn(JUNK),
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
243
|
+
assert.deepEqual(observed.threadUpdates, []);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("hands the re-pointed message to this folder's body sync", async () => {
|
|
247
|
+
const observed = await sync(JUNK, storedIn(INBOX));
|
|
248
|
+
|
|
249
|
+
assert.equal(observed.owned, true);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("what a re-pointed message does to the listing that renders it", () => {
|
|
254
|
+
it("moves the row the listing and the folder counts read", async () => {
|
|
255
|
+
const observed = await sync(JUNK, storedIn(INBOX));
|
|
256
|
+
|
|
257
|
+
assert.deepEqual(observed.threadUpdates, [
|
|
258
|
+
{ mailboxId: JUNK.mailboxId, uid: 42, isDeleted: false },
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("marks the row deleted when the move was into Trash", async () => {
|
|
263
|
+
const observed = await sync(TRASH, storedIn(INBOX));
|
|
264
|
+
|
|
265
|
+
assert.equal(observed.threadUpdates[0].isDeleted, true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("clears the deleted mark when the move was out of Trash", async () => {
|
|
269
|
+
const observed = await sync(INBOX, storedIn(TRASH), threadRowIn(TRASH));
|
|
270
|
+
|
|
271
|
+
assert.equal(observed.threadUpdates[0].isDeleted, false);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("writes nothing when no row renders the message yet", async () => {
|
|
275
|
+
const observed = await sync(JUNK, storedIn(INBOX), null);
|
|
276
|
+
|
|
277
|
+
assert.deepEqual(observed.threadUpdates, []);
|
|
278
|
+
assert.deepEqual(observed.repointedTo, [
|
|
279
|
+
{ mailboxId: JUNK.mailboxId, uid: 42 },
|
|
280
|
+
]);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("what a re-pointed message does to its sender's standing", () => {
|
|
285
|
+
it("re-asks the question when the move was into Junk", async () => {
|
|
286
|
+
const observed = await sync(JUNK, storedIn(INBOX));
|
|
287
|
+
|
|
288
|
+
assert.equal(observed.reconciled.length, 1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("re-asks it again when the move was back out of Junk", async () => {
|
|
292
|
+
const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
|
|
293
|
+
|
|
294
|
+
assert.equal(observed.reconciled.length, 1);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("asks nothing when the pointer did not move", async () => {
|
|
298
|
+
const observed = await sync(INBOX, storedIn(INBOX));
|
|
299
|
+
|
|
300
|
+
assert.deepEqual(observed.reconciled, []);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("repointsOnSighting", () => {
|
|
305
|
+
it("refuses a Gmail virtual folder the server never flagged", () => {
|
|
306
|
+
assert.equal(
|
|
307
|
+
repointsOnSighting(mailboxAt("[Gmail]/All Mail"), storedIn(INBOX)),
|
|
308
|
+
false,
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("accepts a folder the user named after a virtual one", () => {
|
|
313
|
+
assert.equal(
|
|
314
|
+
repointsOnSighting(mailboxAt("Starred ideas"), storedIn(INBOX)),
|
|
315
|
+
true,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("accepts an ordinary inbound row the sync path left pending", () => {
|
|
320
|
+
assert.equal(
|
|
321
|
+
repointsOnSighting(
|
|
322
|
+
JUNK,
|
|
323
|
+
storedIn(INBOX, { syncStatus: MessageSyncStatus.pending }),
|
|
324
|
+
),
|
|
325
|
+
true,
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("accepts a row a settled mutation marked synced", () => {
|
|
330
|
+
assert.equal(
|
|
331
|
+
repointsOnSighting(
|
|
332
|
+
JUNK,
|
|
333
|
+
storedIn(INBOX, { syncStatus: MessageSyncStatus.synced }),
|
|
334
|
+
),
|
|
335
|
+
true,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("refuses a row whose move failed and has not been re-tried", () => {
|
|
340
|
+
assert.equal(
|
|
341
|
+
repointsOnSighting(
|
|
342
|
+
JUNK,
|
|
343
|
+
storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
|
|
344
|
+
),
|
|
345
|
+
false,
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("refuses a row whose own move is still in flight", () => {
|
|
350
|
+
assert.equal(
|
|
351
|
+
repointsOnSighting(
|
|
352
|
+
JUNK,
|
|
353
|
+
storedIn(INBOX, {
|
|
354
|
+
status: MessageStatus.moving,
|
|
355
|
+
syncStatus: MessageSyncStatus.pending,
|
|
356
|
+
}),
|
|
357
|
+
),
|
|
358
|
+
false,
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("refuses a row whose own delete is still in flight", () => {
|
|
363
|
+
assert.equal(
|
|
364
|
+
repointsOnSighting(
|
|
365
|
+
JUNK,
|
|
366
|
+
storedIn(INBOX, {
|
|
367
|
+
status: MessageStatus.deleting,
|
|
368
|
+
syncStatus: MessageSyncStatus.pending,
|
|
369
|
+
}),
|
|
370
|
+
),
|
|
371
|
+
false,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
});
|
package/src/message-sync.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
IThreadMessageRepository,
|
|
11
11
|
IUnitOfWork,
|
|
12
12
|
MailboxItem,
|
|
13
|
+
MessageItem,
|
|
13
14
|
ThreadMessageItem,
|
|
14
15
|
} from "@remit/data-ports";
|
|
15
16
|
import { storedDisplayName } from "@remit/data-ports/display-name";
|
|
@@ -17,15 +18,19 @@ import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
|
|
|
17
18
|
import {
|
|
18
19
|
deriveAddressId,
|
|
19
20
|
deriveBodyPartId,
|
|
21
|
+
deriveCopyMessageId,
|
|
20
22
|
deriveEnvelopeId,
|
|
21
23
|
deriveMessageIdFromSource,
|
|
22
24
|
deriveThreadId,
|
|
23
25
|
isValidMessageId,
|
|
24
26
|
} from "@remit/data-ports/id";
|
|
27
|
+
import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
|
|
25
28
|
import {
|
|
26
29
|
AddressRole,
|
|
27
30
|
MailboxCursorState,
|
|
28
31
|
MessageKeywordFlag,
|
|
32
|
+
MessageStatus,
|
|
33
|
+
MessageSyncStatus,
|
|
29
34
|
MessageSystemFlag,
|
|
30
35
|
StarColor,
|
|
31
36
|
} from "@remit/domain-enums";
|
|
@@ -128,6 +133,40 @@ export const addressSightingIn = (
|
|
|
128
133
|
return "correspondent";
|
|
129
134
|
};
|
|
130
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Whether a sighting of an already-stored message in this mailbox re-points the
|
|
138
|
+
* row at this folder (#859). A message lives in one folder and the model never
|
|
139
|
+
* holds it in two, so a sighting somewhere else is not a rival claim to the
|
|
140
|
+
* row — it is a move another client made under reader. The last move wins, in
|
|
141
|
+
* both directions, and junk withholding derives from the pointer that results.
|
|
142
|
+
*
|
|
143
|
+
* Two sightings are not moves. Gmail's All Mail, Starred and Important hold
|
|
144
|
+
* every message that also lives in a real folder, so they are views rather than
|
|
145
|
+
* filing locations; following one would empty every other folder's listing. And
|
|
146
|
+
* a row whose own mutation has not settled is in flight, not stale
|
|
147
|
+
* (imap-mutations R1): the server's answer predates the move reader is still
|
|
148
|
+
* waiting on, so taking it would undo the optimistic write. The sync path
|
|
149
|
+
* reconciles rather than waits (imap-mutations R2): it never blocks on a
|
|
150
|
+
* mutation, it re-reads the row on the next sighting after the move settles.
|
|
151
|
+
*
|
|
152
|
+
* In flight is read off `status`, never off `syncStatus` (#1096). Every
|
|
153
|
+
* outbound mutation writes `moving` or `deleting` and only settling returns the
|
|
154
|
+
* row to `active`, while an ordinary inbound row stays `pending` for its whole
|
|
155
|
+
* life because nothing on the sync path promotes it — so demanding `synced`
|
|
156
|
+
* here meant ordinary mail never re-pointed at all. `failed` is still refused:
|
|
157
|
+
* a mutation that gave up leaves it set on a row put back to `active`, and
|
|
158
|
+
* nothing routine settles that row afterwards.
|
|
159
|
+
*/
|
|
160
|
+
export const repointsOnSighting = (
|
|
161
|
+
mailbox: MailboxItem,
|
|
162
|
+
message: Pick<MessageItem, "mailboxId" | "status" | "syncStatus">,
|
|
163
|
+
): boolean => {
|
|
164
|
+
if (message.mailboxId === mailbox.mailboxId) return false;
|
|
165
|
+
if (isVirtualCopyMailbox(mailbox)) return false;
|
|
166
|
+
if (message.status !== MessageStatus.active) return false;
|
|
167
|
+
return message.syncStatus !== MessageSyncStatus.failed;
|
|
168
|
+
};
|
|
169
|
+
|
|
131
170
|
/**
|
|
132
171
|
* The name an envelope carries for an address, as it should be stored: any
|
|
133
172
|
* claim to be some other address removed (issue #826), the rest of the name
|
|
@@ -194,8 +233,9 @@ export interface SyncedMessage {
|
|
|
194
233
|
|
|
195
234
|
/**
|
|
196
235
|
* Per-message save outcome. `owned` is true when the row was created by this
|
|
197
|
-
* sync
|
|
198
|
-
*
|
|
236
|
+
* sync, already belongs to the current mailbox, or was re-pointed at it by this
|
|
237
|
+
* sighting; false for a collision this sync declined to follow — a virtual-copy
|
|
238
|
+
* folder, or a row whose own mutation has not settled.
|
|
199
239
|
*/
|
|
200
240
|
interface SaveMessageResult extends SyncedMessage {
|
|
201
241
|
owned: boolean;
|
|
@@ -1443,9 +1483,35 @@ export class MessageSyncService {
|
|
|
1443
1483
|
envelopeId,
|
|
1444
1484
|
rootBodyPartId,
|
|
1445
1485
|
});
|
|
1446
|
-
owned = created || item.mailboxId === mailboxId;
|
|
1447
1486
|
|
|
1448
|
-
|
|
1487
|
+
// The server has just named this folder and this UID for a message the
|
|
1488
|
+
// database holds under another folder. `upsertWithStatus` left the row
|
|
1489
|
+
// alone, so the pointer is repaired here — mailbox and UID together,
|
|
1490
|
+
// because a UID only means anything inside the folder that issued it.
|
|
1491
|
+
const repointed =
|
|
1492
|
+
!created &&
|
|
1493
|
+
repointsOnSighting(mailbox, item) &&
|
|
1494
|
+
!(await this.holdsCopyOf(repos.message, messageId, mailboxId));
|
|
1495
|
+
if (repointed) {
|
|
1496
|
+
await repos.message.updateUid(messageId, msg.uid, mailboxId);
|
|
1497
|
+
await this.repointThreadMessage(
|
|
1498
|
+
repos.threadMessage,
|
|
1499
|
+
accountConfigId,
|
|
1500
|
+
messageId,
|
|
1501
|
+
mailboxId,
|
|
1502
|
+
msg.uid,
|
|
1503
|
+
sighting === "discarded",
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
owned = created || item.mailboxId === mailboxId || repointed;
|
|
1508
|
+
|
|
1509
|
+
// Withholding reads the pointer, so it is re-derived whenever the
|
|
1510
|
+
// pointer could have changed the answer: a save into Junk, and a
|
|
1511
|
+
// re-point in either direction. The reconcile both applies and lifts,
|
|
1512
|
+
// so a sender whose only mail was moved out of Junk is restored by the
|
|
1513
|
+
// same call that would have withheld them (#859).
|
|
1514
|
+
if (sighting === "junk" || repointed) {
|
|
1449
1515
|
await repos.address.reconcileJunkOnlyForMessage(
|
|
1450
1516
|
messageId,
|
|
1451
1517
|
roles.configJunkRoles,
|
|
@@ -1479,6 +1545,69 @@ export class MessageSyncService {
|
|
|
1479
1545
|
return { messageId, uid: msg.uid, owned };
|
|
1480
1546
|
}
|
|
1481
1547
|
|
|
1548
|
+
/**
|
|
1549
|
+
* Whether this folder already holds a copy the user made of this message
|
|
1550
|
+
* (#75). A copy is a second row under its own derived id, so the sighting is
|
|
1551
|
+
* that copy standing where it was put — the original has not moved, and
|
|
1552
|
+
* following it would take the mail out of the folder the user copied it from.
|
|
1553
|
+
*/
|
|
1554
|
+
private async holdsCopyOf(
|
|
1555
|
+
messageService: IMessageRepository,
|
|
1556
|
+
messageId: string,
|
|
1557
|
+
mailboxId: string,
|
|
1558
|
+
): Promise<boolean> {
|
|
1559
|
+
const rows = await messageService.get([
|
|
1560
|
+
deriveCopyMessageId(messageId, mailboxId),
|
|
1561
|
+
]);
|
|
1562
|
+
return rows.length > 0;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Follow a re-pointed Message with the ThreadMessage row that renders it.
|
|
1567
|
+
* The listing and every folder count read that row, so leaving it behind
|
|
1568
|
+
* would show the message in the folder it just left and hide it from the one
|
|
1569
|
+
* it landed in. `isDeleted` follows the same rule the move path uses — true
|
|
1570
|
+
* in Trash, false anywhere else — so a message another client rescues out of
|
|
1571
|
+
* Trash stops being filtered out of listings.
|
|
1572
|
+
*/
|
|
1573
|
+
private async repointThreadMessage(
|
|
1574
|
+
threadMessageService: IThreadMessageRepository,
|
|
1575
|
+
accountConfigId: string,
|
|
1576
|
+
messageId: string,
|
|
1577
|
+
mailboxId: string,
|
|
1578
|
+
uid: number,
|
|
1579
|
+
isDeleted: boolean,
|
|
1580
|
+
): Promise<void> {
|
|
1581
|
+
const row = await threadMessageService.findByMessageId(
|
|
1582
|
+
accountConfigId,
|
|
1583
|
+
messageId,
|
|
1584
|
+
);
|
|
1585
|
+
if (row === null) return;
|
|
1586
|
+
if (
|
|
1587
|
+
row.mailboxId === mailboxId &&
|
|
1588
|
+
row.uid === uid &&
|
|
1589
|
+
row.isDeleted === isDeleted
|
|
1590
|
+
) {
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
await threadMessageService.update(
|
|
1595
|
+
row.accountConfigId,
|
|
1596
|
+
row.threadMessageId,
|
|
1597
|
+
{ mailboxId, uid, isDeleted },
|
|
1598
|
+
{
|
|
1599
|
+
composites: {
|
|
1600
|
+
mailboxId: row.mailboxId,
|
|
1601
|
+
sentDate: row.sentDate,
|
|
1602
|
+
isRead: row.isRead,
|
|
1603
|
+
isDeleted: row.isDeleted,
|
|
1604
|
+
hasStars: row.hasStars,
|
|
1605
|
+
hasAttachment: row.hasAttachment,
|
|
1606
|
+
},
|
|
1607
|
+
},
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1482
1611
|
private async saveAddresses(
|
|
1483
1612
|
addressService: IAddressRepository,
|
|
1484
1613
|
messageId: string,
|