@remit/mailbox-service 0.0.79 → 0.0.81
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
CHANGED
|
@@ -19,7 +19,6 @@ import type { ImapEnvelope } from "./types.js";
|
|
|
19
19
|
// in another client arrived unstarred and never appeared in Flagged. The
|
|
20
20
|
// server's \Flagged keyword is the star; these cover the mapping on create.
|
|
21
21
|
|
|
22
|
-
const ACCOUNT_ID = "acct-1";
|
|
23
22
|
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
24
23
|
const MAILBOX_ID = "mbx-1";
|
|
25
24
|
const MESSAGE_ID = "msg-1";
|
|
@@ -66,7 +65,6 @@ type CreateThreadForMessage = (
|
|
|
66
65
|
threadMessageService: IThreadMessageRepository,
|
|
67
66
|
messageId: string,
|
|
68
67
|
mailboxId: string,
|
|
69
|
-
accountId: string,
|
|
70
68
|
accountConfigId: string,
|
|
71
69
|
uid: number,
|
|
72
70
|
internalDate: number,
|
|
@@ -99,7 +97,6 @@ const createThreadWithFlags = async (
|
|
|
99
97
|
repo,
|
|
100
98
|
MESSAGE_ID,
|
|
101
99
|
MAILBOX_ID,
|
|
102
|
-
ACCOUNT_ID,
|
|
103
100
|
ACCOUNT_CONFIG_ID,
|
|
104
101
|
42,
|
|
105
102
|
now,
|
|
@@ -0,0 +1,322 @@
|
|
|
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
|
+
const storedIn = (
|
|
53
|
+
mailbox: MailboxItem,
|
|
54
|
+
overrides: Partial<MessageItem> = {},
|
|
55
|
+
): MessageItem =>
|
|
56
|
+
({
|
|
57
|
+
mailboxId: mailbox.mailboxId,
|
|
58
|
+
uid: 7,
|
|
59
|
+
status: MessageStatus.active,
|
|
60
|
+
syncStatus: MessageSyncStatus.synced,
|
|
61
|
+
...overrides,
|
|
62
|
+
}) as MessageItem;
|
|
63
|
+
|
|
64
|
+
const threadRowIn = (mailbox: MailboxItem): ThreadMessageItem =>
|
|
65
|
+
({
|
|
66
|
+
threadMessageId: "tm-1",
|
|
67
|
+
accountConfigId: "cfg-1",
|
|
68
|
+
mailboxId: mailbox.mailboxId,
|
|
69
|
+
uid: 7,
|
|
70
|
+
sentDate: 0,
|
|
71
|
+
isRead: false,
|
|
72
|
+
isDeleted: mailbox === TRASH,
|
|
73
|
+
hasStars: false,
|
|
74
|
+
hasAttachment: false,
|
|
75
|
+
}) as ThreadMessageItem;
|
|
76
|
+
|
|
77
|
+
interface Observed {
|
|
78
|
+
repointedTo: Array<{ mailboxId: string; uid: number }>;
|
|
79
|
+
threadUpdates: UpdateThreadMessageInput[];
|
|
80
|
+
reconciled: string[];
|
|
81
|
+
owned: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Sync one message out of `sighting`, against a database that already holds it
|
|
86
|
+
* under `stored`. The account's Junk folder is `JUNK` and its Trash is `TRASH`,
|
|
87
|
+
* so the sighting's own role follows from which mailbox it is.
|
|
88
|
+
*/
|
|
89
|
+
const sync = async (
|
|
90
|
+
sighting: MailboxItem,
|
|
91
|
+
stored: MessageItem,
|
|
92
|
+
threadRow: ThreadMessageItem | null = threadRowIn(INBOX),
|
|
93
|
+
copiesHere: MessageItem[] = [],
|
|
94
|
+
): Promise<Observed> => {
|
|
95
|
+
const observed: Observed = {
|
|
96
|
+
repointedTo: [],
|
|
97
|
+
threadUpdates: [],
|
|
98
|
+
reconciled: [],
|
|
99
|
+
owned: false,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const messageService = {
|
|
103
|
+
upsertWithStatus: async () => ({ item: stored, created: false }),
|
|
104
|
+
get: async () => copiesHere,
|
|
105
|
+
updateUid: async (_id: string, uid: number, mailboxId: string) => {
|
|
106
|
+
observed.repointedTo.push({ mailboxId, uid });
|
|
107
|
+
return stored;
|
|
108
|
+
},
|
|
109
|
+
} as unknown as IMessageRepository;
|
|
110
|
+
|
|
111
|
+
const threadMessageService = {
|
|
112
|
+
findByMessageId: async () => threadRow,
|
|
113
|
+
update: async (
|
|
114
|
+
_config: string,
|
|
115
|
+
_id: string,
|
|
116
|
+
input: UpdateThreadMessageInput,
|
|
117
|
+
) => {
|
|
118
|
+
observed.threadUpdates.push(input);
|
|
119
|
+
return threadRow as ThreadMessageItem;
|
|
120
|
+
},
|
|
121
|
+
create: async (input: unknown) => input as ThreadMessageItem,
|
|
122
|
+
} as unknown as IThreadMessageRepository;
|
|
123
|
+
|
|
124
|
+
const envelopeService = {
|
|
125
|
+
upsertEnvelope: async () => {},
|
|
126
|
+
upsertBodyParts: async () => {},
|
|
127
|
+
} as unknown as IEnvelopeRepository;
|
|
128
|
+
|
|
129
|
+
const addressService = {
|
|
130
|
+
upsertCorrespondentAddress: async () => {},
|
|
131
|
+
upsertJunkAddress: async () => {},
|
|
132
|
+
upsertAddress: async () => {},
|
|
133
|
+
upsertEnvelopeAddress: async () => {},
|
|
134
|
+
reconcileJunkOnlyForMessage: async (messageId: string) => {
|
|
135
|
+
observed.reconciled.push(messageId);
|
|
136
|
+
},
|
|
137
|
+
} as unknown as IAddressRepository;
|
|
138
|
+
|
|
139
|
+
const service = new MessageSyncService(
|
|
140
|
+
stub<ManagedConnectionFactory>(),
|
|
141
|
+
stub<IMailboxRepository>(),
|
|
142
|
+
folderRoles({
|
|
143
|
+
junkMailboxId: JUNK.mailboxId,
|
|
144
|
+
trashMailboxId: TRASH.mailboxId,
|
|
145
|
+
}),
|
|
146
|
+
messageService,
|
|
147
|
+
envelopeService,
|
|
148
|
+
addressService,
|
|
149
|
+
threadMessageService,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const msg = {
|
|
153
|
+
uid: 42,
|
|
154
|
+
seq: 1,
|
|
155
|
+
size: 100,
|
|
156
|
+
internalDate: new Date(0),
|
|
157
|
+
flags: [],
|
|
158
|
+
envelope,
|
|
159
|
+
} as unknown as ImapMessage;
|
|
160
|
+
|
|
161
|
+
const result = (await (
|
|
162
|
+
service as unknown as {
|
|
163
|
+
saveMessage: (
|
|
164
|
+
mailbox: MailboxItem,
|
|
165
|
+
accountId: string,
|
|
166
|
+
accountConfigId: string,
|
|
167
|
+
msg: ImapMessage,
|
|
168
|
+
roles: AccountFolderRoles,
|
|
169
|
+
) => Promise<{ owned: boolean }>;
|
|
170
|
+
}
|
|
171
|
+
).saveMessage(sighting, "acct-1", "cfg-1", msg, {
|
|
172
|
+
junkMailboxId: JUNK.mailboxId,
|
|
173
|
+
trashMailboxId: TRASH.mailboxId,
|
|
174
|
+
configJunkRoles: NO_JUNK_ROLES,
|
|
175
|
+
})) as { owned: boolean };
|
|
176
|
+
|
|
177
|
+
observed.owned = result.owned;
|
|
178
|
+
return observed;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
describe("which folder a message the database already holds lives in", () => {
|
|
182
|
+
it("follows the last move when another client files it into Junk", async () => {
|
|
183
|
+
const observed = await sync(JUNK, storedIn(INBOX));
|
|
184
|
+
|
|
185
|
+
assert.deepEqual(observed.repointedTo, [
|
|
186
|
+
{ mailboxId: JUNK.mailboxId, uid: 42 },
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("follows the last move back when another client rescues it", async () => {
|
|
191
|
+
const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
|
|
192
|
+
|
|
193
|
+
assert.deepEqual(observed.repointedTo, [
|
|
194
|
+
{ mailboxId: INBOX.mailboxId, uid: 42 },
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("takes the UID of the folder that issued it, never the old one", async () => {
|
|
199
|
+
const observed = await sync(JUNK, storedIn(INBOX, { uid: 7 }));
|
|
200
|
+
|
|
201
|
+
assert.equal(observed.repointedTo[0].uid, 42);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("leaves a row this folder already owns alone", async () => {
|
|
205
|
+
const observed = await sync(INBOX, storedIn(INBOX));
|
|
206
|
+
|
|
207
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
208
|
+
assert.equal(observed.owned, true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("holds a row whose own move has not settled", async () => {
|
|
212
|
+
const observed = await sync(
|
|
213
|
+
INBOX,
|
|
214
|
+
storedIn(JUNK, {
|
|
215
|
+
status: MessageStatus.moving,
|
|
216
|
+
syncStatus: MessageSyncStatus.pending,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
221
|
+
assert.equal(observed.owned, false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("declines a sighting in a folder that copies every message", async () => {
|
|
225
|
+
const observed = await sync(ALL_MAIL, storedIn(INBOX));
|
|
226
|
+
|
|
227
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
228
|
+
assert.equal(observed.owned, false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("declines a sighting of a copy the user put in this folder", async () => {
|
|
232
|
+
const observed = await sync(JUNK, storedIn(INBOX), threadRowIn(INBOX), [
|
|
233
|
+
storedIn(JUNK),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
237
|
+
assert.deepEqual(observed.threadUpdates, []);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("hands the re-pointed message to this folder's body sync", async () => {
|
|
241
|
+
const observed = await sync(JUNK, storedIn(INBOX));
|
|
242
|
+
|
|
243
|
+
assert.equal(observed.owned, true);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("what a re-pointed message does to the listing that renders it", () => {
|
|
248
|
+
it("moves the row the listing and the folder counts read", async () => {
|
|
249
|
+
const observed = await sync(JUNK, storedIn(INBOX));
|
|
250
|
+
|
|
251
|
+
assert.deepEqual(observed.threadUpdates, [
|
|
252
|
+
{ mailboxId: JUNK.mailboxId, uid: 42, isDeleted: false },
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("marks the row deleted when the move was into Trash", async () => {
|
|
257
|
+
const observed = await sync(TRASH, storedIn(INBOX));
|
|
258
|
+
|
|
259
|
+
assert.equal(observed.threadUpdates[0].isDeleted, true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("clears the deleted mark when the move was out of Trash", async () => {
|
|
263
|
+
const observed = await sync(INBOX, storedIn(TRASH), threadRowIn(TRASH));
|
|
264
|
+
|
|
265
|
+
assert.equal(observed.threadUpdates[0].isDeleted, false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("writes nothing when no row renders the message yet", async () => {
|
|
269
|
+
const observed = await sync(JUNK, storedIn(INBOX), null);
|
|
270
|
+
|
|
271
|
+
assert.deepEqual(observed.threadUpdates, []);
|
|
272
|
+
assert.deepEqual(observed.repointedTo, [
|
|
273
|
+
{ mailboxId: JUNK.mailboxId, uid: 42 },
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("what a re-pointed message does to its sender's standing", () => {
|
|
279
|
+
it("re-asks the question when the move was into Junk", async () => {
|
|
280
|
+
const observed = await sync(JUNK, storedIn(INBOX));
|
|
281
|
+
|
|
282
|
+
assert.equal(observed.reconciled.length, 1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("re-asks it again when the move was back out of Junk", async () => {
|
|
286
|
+
const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
|
|
287
|
+
|
|
288
|
+
assert.equal(observed.reconciled.length, 1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("asks nothing when the pointer did not move", async () => {
|
|
292
|
+
const observed = await sync(INBOX, storedIn(INBOX));
|
|
293
|
+
|
|
294
|
+
assert.deepEqual(observed.reconciled, []);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("repointsOnSighting", () => {
|
|
299
|
+
it("refuses a Gmail virtual folder the server never flagged", () => {
|
|
300
|
+
assert.equal(
|
|
301
|
+
repointsOnSighting(mailboxAt("[Gmail]/All Mail"), storedIn(INBOX)),
|
|
302
|
+
false,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("accepts a folder the user named after a virtual one", () => {
|
|
307
|
+
assert.equal(
|
|
308
|
+
repointsOnSighting(mailboxAt("Starred ideas"), storedIn(INBOX)),
|
|
309
|
+
true,
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("refuses a row whose move failed and has not been re-tried", () => {
|
|
314
|
+
assert.equal(
|
|
315
|
+
repointsOnSighting(
|
|
316
|
+
JUNK,
|
|
317
|
+
storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
|
|
318
|
+
),
|
|
319
|
+
false,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
});
|
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,33 @@ 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. That sighting is
|
|
149
|
+
* reconciled by the next one after the mutation settles.
|
|
150
|
+
*/
|
|
151
|
+
export const repointsOnSighting = (
|
|
152
|
+
mailbox: MailboxItem,
|
|
153
|
+
message: Pick<MessageItem, "mailboxId" | "status" | "syncStatus">,
|
|
154
|
+
): boolean => {
|
|
155
|
+
if (message.mailboxId === mailbox.mailboxId) return false;
|
|
156
|
+
if (isVirtualCopyMailbox(mailbox)) return false;
|
|
157
|
+
return (
|
|
158
|
+
message.status === MessageStatus.active &&
|
|
159
|
+
message.syncStatus === MessageSyncStatus.synced
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
131
163
|
/**
|
|
132
164
|
* The name an envelope carries for an address, as it should be stored: any
|
|
133
165
|
* claim to be some other address removed (issue #826), the rest of the name
|
|
@@ -194,8 +226,9 @@ export interface SyncedMessage {
|
|
|
194
226
|
|
|
195
227
|
/**
|
|
196
228
|
* Per-message save outcome. `owned` is true when the row was created by this
|
|
197
|
-
* sync
|
|
198
|
-
*
|
|
229
|
+
* sync, already belongs to the current mailbox, or was re-pointed at it by this
|
|
230
|
+
* sighting; false for a collision this sync declined to follow — a virtual-copy
|
|
231
|
+
* folder, or a row whose own mutation has not settled.
|
|
199
232
|
*/
|
|
200
233
|
interface SaveMessageResult extends SyncedMessage {
|
|
201
234
|
owned: boolean;
|
|
@@ -1443,9 +1476,35 @@ export class MessageSyncService {
|
|
|
1443
1476
|
envelopeId,
|
|
1444
1477
|
rootBodyPartId,
|
|
1445
1478
|
});
|
|
1446
|
-
owned = created || item.mailboxId === mailboxId;
|
|
1447
1479
|
|
|
1448
|
-
|
|
1480
|
+
// The server has just named this folder and this UID for a message the
|
|
1481
|
+
// database holds under another folder. `upsertWithStatus` left the row
|
|
1482
|
+
// alone, so the pointer is repaired here — mailbox and UID together,
|
|
1483
|
+
// because a UID only means anything inside the folder that issued it.
|
|
1484
|
+
const repointed =
|
|
1485
|
+
!created &&
|
|
1486
|
+
repointsOnSighting(mailbox, item) &&
|
|
1487
|
+
!(await this.holdsCopyOf(repos.message, messageId, mailboxId));
|
|
1488
|
+
if (repointed) {
|
|
1489
|
+
await repos.message.updateUid(messageId, msg.uid, mailboxId);
|
|
1490
|
+
await this.repointThreadMessage(
|
|
1491
|
+
repos.threadMessage,
|
|
1492
|
+
accountConfigId,
|
|
1493
|
+
messageId,
|
|
1494
|
+
mailboxId,
|
|
1495
|
+
msg.uid,
|
|
1496
|
+
sighting === "discarded",
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
owned = created || item.mailboxId === mailboxId || repointed;
|
|
1501
|
+
|
|
1502
|
+
// Withholding reads the pointer, so it is re-derived whenever the
|
|
1503
|
+
// pointer could have changed the answer: a save into Junk, and a
|
|
1504
|
+
// re-point in either direction. The reconcile both applies and lifts,
|
|
1505
|
+
// so a sender whose only mail was moved out of Junk is restored by the
|
|
1506
|
+
// same call that would have withheld them (#859).
|
|
1507
|
+
if (sighting === "junk" || repointed) {
|
|
1449
1508
|
await repos.address.reconcileJunkOnlyForMessage(
|
|
1450
1509
|
messageId,
|
|
1451
1510
|
roles.configJunkRoles,
|
|
@@ -1456,7 +1515,6 @@ export class MessageSyncService {
|
|
|
1456
1515
|
repos.threadMessage,
|
|
1457
1516
|
messageId,
|
|
1458
1517
|
mailboxId,
|
|
1459
|
-
accountId,
|
|
1460
1518
|
accountConfigId,
|
|
1461
1519
|
msg.uid,
|
|
1462
1520
|
msg.internalDate.getTime(),
|
|
@@ -1480,6 +1538,69 @@ export class MessageSyncService {
|
|
|
1480
1538
|
return { messageId, uid: msg.uid, owned };
|
|
1481
1539
|
}
|
|
1482
1540
|
|
|
1541
|
+
/**
|
|
1542
|
+
* Whether this folder already holds a copy the user made of this message
|
|
1543
|
+
* (#75). A copy is a second row under its own derived id, so the sighting is
|
|
1544
|
+
* that copy standing where it was put — the original has not moved, and
|
|
1545
|
+
* following it would take the mail out of the folder the user copied it from.
|
|
1546
|
+
*/
|
|
1547
|
+
private async holdsCopyOf(
|
|
1548
|
+
messageService: IMessageRepository,
|
|
1549
|
+
messageId: string,
|
|
1550
|
+
mailboxId: string,
|
|
1551
|
+
): Promise<boolean> {
|
|
1552
|
+
const rows = await messageService.get([
|
|
1553
|
+
deriveCopyMessageId(messageId, mailboxId),
|
|
1554
|
+
]);
|
|
1555
|
+
return rows.length > 0;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Follow a re-pointed Message with the ThreadMessage row that renders it.
|
|
1560
|
+
* The listing and every folder count read that row, so leaving it behind
|
|
1561
|
+
* would show the message in the folder it just left and hide it from the one
|
|
1562
|
+
* it landed in. `isDeleted` follows the same rule the move path uses — true
|
|
1563
|
+
* in Trash, false anywhere else — so a message another client rescues out of
|
|
1564
|
+
* Trash stops being filtered out of listings.
|
|
1565
|
+
*/
|
|
1566
|
+
private async repointThreadMessage(
|
|
1567
|
+
threadMessageService: IThreadMessageRepository,
|
|
1568
|
+
accountConfigId: string,
|
|
1569
|
+
messageId: string,
|
|
1570
|
+
mailboxId: string,
|
|
1571
|
+
uid: number,
|
|
1572
|
+
isDeleted: boolean,
|
|
1573
|
+
): Promise<void> {
|
|
1574
|
+
const row = await threadMessageService.findByMessageId(
|
|
1575
|
+
accountConfigId,
|
|
1576
|
+
messageId,
|
|
1577
|
+
);
|
|
1578
|
+
if (row === null) return;
|
|
1579
|
+
if (
|
|
1580
|
+
row.mailboxId === mailboxId &&
|
|
1581
|
+
row.uid === uid &&
|
|
1582
|
+
row.isDeleted === isDeleted
|
|
1583
|
+
) {
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
await threadMessageService.update(
|
|
1588
|
+
row.accountConfigId,
|
|
1589
|
+
row.threadMessageId,
|
|
1590
|
+
{ mailboxId, uid, isDeleted },
|
|
1591
|
+
{
|
|
1592
|
+
composites: {
|
|
1593
|
+
mailboxId: row.mailboxId,
|
|
1594
|
+
sentDate: row.sentDate,
|
|
1595
|
+
isRead: row.isRead,
|
|
1596
|
+
isDeleted: row.isDeleted,
|
|
1597
|
+
hasStars: row.hasStars,
|
|
1598
|
+
hasAttachment: row.hasAttachment,
|
|
1599
|
+
},
|
|
1600
|
+
},
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1483
1604
|
private async saveAddresses(
|
|
1484
1605
|
addressService: IAddressRepository,
|
|
1485
1606
|
messageId: string,
|
|
@@ -1569,7 +1690,6 @@ export class MessageSyncService {
|
|
|
1569
1690
|
threadMessageService: IThreadMessageRepository,
|
|
1570
1691
|
messageId: string,
|
|
1571
1692
|
mailboxId: string,
|
|
1572
|
-
accountId: string,
|
|
1573
1693
|
accountConfigId: string,
|
|
1574
1694
|
uid: number,
|
|
1575
1695
|
internalDate: number,
|
|
@@ -1603,7 +1723,7 @@ export class MessageSyncService {
|
|
|
1603
1723
|
}
|
|
1604
1724
|
|
|
1605
1725
|
// Derive threadId from the root Message-ID (deterministic)
|
|
1606
|
-
const threadId = deriveThreadId(
|
|
1726
|
+
const threadId = deriveThreadId(accountConfigId, rootMessageIdHeader);
|
|
1607
1727
|
|
|
1608
1728
|
const isRead = flags.includes(MessageSystemFlag.Seen);
|
|
1609
1729
|
|