@remit/mailbox-service 0.0.112 → 0.0.113
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/dist/account-credentials.d.ts +83 -0
- package/dist/account-credentials.js +104 -0
- package/dist/attribute-mapper.d.ts +36 -0
- package/dist/attribute-mapper.js +119 -0
- package/dist/body-parse.d.ts +28 -0
- package/{src/body-parse.ts → dist/body-parse.js} +18 -26
- package/dist/body-part-mapper.d.ts +92 -0
- package/dist/body-part-mapper.js +301 -0
- package/dist/body-sync-queue.d.ts +59 -0
- package/dist/body-sync-queue.js +56 -0
- package/dist/body-sync-terminal.d.ts +58 -0
- package/dist/body-sync-terminal.js +95 -0
- package/dist/body-sync.d.ts +351 -0
- package/dist/body-sync.js +1204 -0
- package/dist/calendar-parts.d.ts +23 -0
- package/dist/calendar-parts.js +18 -0
- package/dist/classification-backfill.d.ts +91 -0
- package/dist/classification-backfill.js +180 -0
- package/dist/classify-message.d.ts +31 -0
- package/dist/classify-message.js +89 -0
- package/dist/connection-factory.d.ts +54 -0
- package/dist/connection-factory.js +54 -0
- package/dist/connection-test.d.ts +41 -0
- package/dist/connection-test.js +152 -0
- package/dist/external-move.d.ts +94 -0
- package/dist/external-move.js +173 -0
- package/dist/filter-operations.d.ts +42 -0
- package/dist/filter-operations.js +211 -0
- package/dist/filters/filter-config.d.ts +14 -0
- package/dist/filters/filter-config.js +10 -0
- package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
- package/dist/filters/list-id.js +26 -0
- package/dist/filters/match.d.ts +69 -0
- package/dist/filters/match.js +165 -0
- package/dist/filters/pipeline.d.ts +64 -0
- package/dist/filters/pipeline.js +100 -0
- package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
- package/dist/flag-push-terminal.js +63 -0
- package/dist/flag-push.d.ts +69 -0
- package/dist/flag-push.js +111 -0
- package/dist/flag-queue.d.ts +175 -0
- package/dist/flag-queue.js +291 -0
- package/dist/folder-operations.d.ts +39 -0
- package/dist/folder-operations.js +112 -0
- package/dist/folder-placement.d.ts +13 -0
- package/dist/folder-placement.js +23 -0
- package/dist/heuristics/authenticationResults.d.ts +10 -0
- package/dist/heuristics/authenticationResults.js +187 -0
- package/dist/heuristics/classifyByHeaders.d.ts +82 -0
- package/dist/heuristics/classifyByHeaders.js +365 -0
- package/dist/heuristics/classifyPlacement.d.ts +67 -0
- package/dist/heuristics/classifyPlacement.js +153 -0
- package/dist/heuristics/machineSenders.d.ts +21 -0
- package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
- package/dist/heuristics/senderMismatch.d.ts +81 -0
- package/dist/heuristics/senderMismatch.js +192 -0
- package/dist/heuristics/socialDomains.d.ts +14 -0
- package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
- package/dist/heuristics/transactionalDomains.d.ts +18 -0
- package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
- package/dist/imapflow-connection.d.ts +377 -0
- package/dist/imapflow-connection.js +1147 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +65 -0
- package/dist/list-id-backfill.d.ts +83 -0
- package/dist/list-id-backfill.js +132 -0
- package/dist/mailbox-cursor-rebuild.d.ts +74 -0
- package/dist/mailbox-cursor-rebuild.js +71 -0
- package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
- package/dist/mailbox-cursor.js +102 -0
- package/dist/mailbox-intent.d.ts +41 -0
- package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
- package/dist/mailbox-management.d.ts +240 -0
- package/dist/mailbox-management.js +576 -0
- package/dist/mailbox-presence.d.ts +52 -0
- package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
- package/dist/mailbox-queue.d.ts +126 -0
- package/dist/mailbox-queue.js +214 -0
- package/dist/mailbox-sync.d.ts +93 -0
- package/dist/mailbox-sync.js +490 -0
- package/dist/mailbox-upstream.d.ts +38 -0
- package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
- package/dist/message-category.d.ts +51 -0
- package/{src/message-category.ts → dist/message-category.js} +28 -71
- package/dist/message-move.d.ts +285 -0
- package/dist/message-move.js +858 -0
- package/dist/message-parser.d.ts +14 -0
- package/dist/message-parser.js +17 -0
- package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
- package/dist/message-presence.js +29 -0
- package/dist/message-read.d.ts +51 -0
- package/dist/message-read.js +31 -0
- package/dist/message-sync.d.ts +345 -0
- package/dist/message-sync.js +1358 -0
- package/dist/mime-walker.d.ts +102 -0
- package/dist/mime-walker.js +198 -0
- package/dist/organize-job.d.ts +20 -0
- package/dist/organize-job.js +37 -0
- package/dist/outbox-attachment-content.d.ts +37 -0
- package/dist/outbox-attachment-content.js +50 -0
- package/dist/outbox-attachment-filename.d.ts +19 -0
- package/dist/outbox-attachment-filename.js +64 -0
- package/dist/outbox-attachment.d.ts +148 -0
- package/dist/outbox-attachment.js +273 -0
- package/dist/outbox-queue.d.ts +73 -0
- package/dist/outbox-queue.js +284 -0
- package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
- package/dist/outbox-status.js +18 -0
- package/dist/ownership.d.ts +24 -0
- package/dist/ownership.js +83 -0
- package/dist/placement-move-terminal.d.ts +67 -0
- package/dist/placement-move-terminal.js +88 -0
- package/dist/placement-move.d.ts +83 -0
- package/dist/placement-move.js +216 -0
- package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
- package/dist/placement-settled.js +75 -0
- package/dist/quarantine.d.ts +95 -0
- package/dist/quarantine.js +131 -0
- package/dist/refusal.d.ts +31 -0
- package/dist/refusal.js +3 -0
- package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
- package/dist/restore-source-placement.js +79 -0
- package/dist/search-text-backfill.d.ts +15 -0
- package/dist/search-text-backfill.js +33 -0
- package/dist/snippet.d.ts +61 -0
- package/dist/snippet.js +129 -0
- package/dist/spam-report.d.ts +86 -0
- package/dist/spam-report.js +241 -0
- package/dist/sync-warning.d.ts +21 -0
- package/dist/sync-warning.js +26 -0
- package/dist/sync-watermarks.d.ts +138 -0
- package/dist/sync-watermarks.js +205 -0
- package/dist/test-helpers/fake-imap.d.ts +151 -0
- package/dist/test-helpers/fake-imap.js +508 -0
- package/dist/test-helpers/folder-roles.d.ts +34 -0
- package/dist/test-helpers/folder-roles.js +47 -0
- package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
- package/dist/test-helpers/mime-tree-generator.js +329 -0
- package/dist/test-helpers/test-store.d.ts +69 -0
- package/dist/test-helpers/test-store.js +193 -0
- package/dist/text/normalizer.d.ts +20 -0
- package/dist/text/normalizer.js +102 -0
- package/dist/thread-message-rows.d.ts +69 -0
- package/dist/thread-message-rows.js +40 -0
- package/dist/thread-root.d.ts +7 -0
- package/dist/thread-root.js +25 -0
- package/dist/types.d.ts +343 -0
- package/dist/types.js +19 -0
- package/package.json +86 -88
- package/src/account-credentials.ts +0 -210
- package/src/attribute-mapper.test.ts +0 -104
- package/src/attribute-mapper.ts +0 -148
- package/src/body-parse.test.ts +0 -55
- package/src/body-part-mapper.property.test.ts +0 -354
- package/src/body-part-mapper.ts +0 -410
- package/src/body-sync-queue.test.ts +0 -64
- package/src/body-sync-queue.ts +0 -131
- package/src/body-sync-terminal.test.ts +0 -148
- package/src/body-sync-terminal.ts +0 -174
- package/src/body-sync.test.ts +0 -925
- package/src/body-sync.ts +0 -1891
- package/src/calendar-parts.ts +0 -41
- package/src/classification-backfill.test.ts +0 -285
- package/src/classification-backfill.ts +0 -363
- package/src/classify-message.test.ts +0 -142
- package/src/classify-message.ts +0 -116
- package/src/connection-factory.ts +0 -102
- package/src/connection-test.ts +0 -208
- package/src/credentials.test.ts +0 -181
- package/src/external-move.test.ts +0 -262
- package/src/external-move.ts +0 -285
- package/src/filter-operations.test.ts +0 -319
- package/src/filter-operations.ts +0 -377
- package/src/filter-update.test.ts +0 -313
- package/src/filters/filter-config.test.ts +0 -87
- package/src/filters/filter-config.ts +0 -34
- package/src/filters/list-id.test.ts +0 -50
- package/src/filters/match.test.ts +0 -392
- package/src/filters/match.ts +0 -225
- package/src/filters/pipeline.test.ts +0 -199
- package/src/filters/pipeline.ts +0 -170
- package/src/flag-push.test.ts +0 -112
- package/src/flag-push.ts +0 -198
- package/src/flag-queue.test.ts +0 -197
- package/src/flag-queue.ts +0 -509
- package/src/folder-operations.test.ts +0 -273
- package/src/folder-operations.ts +0 -217
- package/src/folder-placement.ts +0 -56
- package/src/forwarded-message.test.ts +0 -122
- package/src/heuristics/authenticationResults.test.ts +0 -189
- package/src/heuristics/authenticationResults.ts +0 -212
- package/src/heuristics/classifyByHeaders.test.ts +0 -889
- package/src/heuristics/classifyByHeaders.ts +0 -467
- package/src/heuristics/classifyPlacement.test.ts +0 -366
- package/src/heuristics/classifyPlacement.ts +0 -211
- package/src/heuristics/classifyRealMail.test.ts +0 -242
- package/src/heuristics/machineSenders.test.ts +0 -101
- package/src/heuristics/senderMismatch.test.ts +0 -346
- package/src/heuristics/senderMismatch.ts +0 -259
- package/src/imapflow-connect-retry.test.ts +0 -97
- package/src/imapflow-connection.test.ts +0 -503
- package/src/imapflow-connection.ts +0 -1476
- package/src/index.ts +0 -397
- package/src/list-id-backfill.test.ts +0 -207
- package/src/list-id-backfill.ts +0 -274
- package/src/mailbox-cursor-rebuild.test.ts +0 -171
- package/src/mailbox-cursor-rebuild.ts +0 -146
- package/src/mailbox-cursor.test.ts +0 -141
- package/src/mailbox-delete-intent.test.ts +0 -119
- package/src/mailbox-management.test.ts +0 -243
- package/src/mailbox-management.ts +0 -838
- package/src/mailbox-presence.test.ts +0 -74
- package/src/mailbox-queue.ts +0 -397
- package/src/mailbox-rename-intent.test.ts +0 -220
- package/src/mailbox-rename-subtree.test.ts +0 -287
- package/src/mailbox-sync.test.ts +0 -286
- package/src/mailbox-sync.ts +0 -724
- package/src/message-move.test.ts +0 -726
- package/src/message-move.ts +0 -1518
- package/src/message-parser.ts +0 -26
- package/src/message-presence.test.ts +0 -50
- package/src/message-read.ts +0 -87
- package/src/message-save-retry.test.ts +0 -90
- package/src/message-sync-flags.test.ts +0 -167
- package/src/message-sync.test.ts +0 -762
- package/src/message-sync.ts +0 -2133
- package/src/mime-walker.test.ts +0 -311
- package/src/mime-walker.ts +0 -306
- package/src/organize-job.ts +0 -93
- package/src/outbox-attachment-content.test.ts +0 -153
- package/src/outbox-attachment-content.ts +0 -104
- package/src/outbox-attachment-filename.test.ts +0 -132
- package/src/outbox-attachment-filename.ts +0 -76
- package/src/outbox-attachment.test.ts +0 -626
- package/src/outbox-attachment.ts +0 -536
- package/src/outbox-failed-recovery.test.ts +0 -299
- package/src/outbox-queue.test.ts +0 -315
- package/src/outbox-queue.ts +0 -525
- package/src/ownership.ts +0 -140
- package/src/placement-move-terminal.ts +0 -145
- package/src/placement-move.ts +0 -363
- package/src/placement-settled.test.ts +0 -147
- package/src/placement.test.ts +0 -404
- package/src/quarantine.test.ts +0 -133
- package/src/quarantine.ts +0 -229
- package/src/refusal.ts +0 -29
- package/src/snippet.test.ts +0 -213
- package/src/snippet.ts +0 -152
- package/src/spam-report.test.ts +0 -329
- package/src/spam-report.ts +0 -357
- package/src/sync-warning.test.ts +0 -213
- package/src/sync-warning.ts +0 -56
- package/src/sync-watermarks.test.ts +0 -409
- package/src/sync-watermarks.ts +0 -319
- package/src/system-flag-wire-format.test.ts +0 -77
- package/src/test-helpers/fake-imap.ts +0 -690
- package/src/test-helpers/folder-roles.ts +0 -66
- package/src/test-helpers/mime-tree-generator.ts +0 -461
- package/src/test-helpers/test-queue.ts +0 -56
- package/src/test-helpers/test-store.ts +0 -299
- package/src/text/normalizer.test.ts +0 -223
- package/src/text/normalizer.ts +0 -140
- package/src/thread-message-rows.ts +0 -77
- package/src/thread-root.test.ts +0 -29
- package/src/thread-root.ts +0 -51
- package/src/types/mailparser-augment.d.ts +0 -23
- package/src/types/natural-porter-stemmer.d.ts +0 -50
- package/src/types/stopword.d.ts +0 -69
- package/src/types.ts +0 -371
- package/tsconfig.json +0 -8
|
@@ -0,0 +1,1358 @@
|
|
|
1
|
+
import { storedDisplayName } from "@remit/data-ports/display-name";
|
|
2
|
+
import { deriveAddressId, deriveBodyPartId, deriveCopyMessageId, deriveEnvelopeId, deriveMessageId, deriveMessageIdFromSource, deriveThreadId, isValidMessageId, wireMessageId, } from "@remit/data-ports/id";
|
|
3
|
+
import { AddressRole, MailboxCursorState, MessageKeywordFlag, MessageSystemFlag, StarColor, SyncWarningStage, } from "@remit/domain-enums";
|
|
4
|
+
import pMap from "p-map";
|
|
5
|
+
import { confirmDepartures, placementKey, sightingContestsPlacement, } from "./external-move.js";
|
|
6
|
+
import { guardMailboxCursor, isCursorRebuildNeeded } from "./mailbox-cursor.js";
|
|
7
|
+
import { matchCursorRebuild, } from "./mailbox-cursor-rebuild.js";
|
|
8
|
+
import { ROOT_PART_PATH, walkMimeStructure } from "./mime-walker.js";
|
|
9
|
+
import { advanceChangeCursor, advanceUidWatermarks, dropAppliedPrefix, formatChangeCursor, hasChangeCursor, orderByModseq, parseChangeCursor, parseModseq, } from "./sync-watermarks.js";
|
|
10
|
+
import { messageReferencesOf, parentOf, threadRootOf } from "./thread-root.js";
|
|
11
|
+
const MESSAGE_SAVE_CONCURRENCY = 10;
|
|
12
|
+
// Some IMAP servers (e.g. Hostnet) emit these literal placeholders in the
|
|
13
|
+
// ENVELOPE when they cannot parse a From header, instead of leaving the
|
|
14
|
+
// address parts empty.
|
|
15
|
+
const HOSTNET_MISSING_MAILBOX = "missing_mailbox";
|
|
16
|
+
const HOSTNET_MISSING_DOMAIN = "missing_domain";
|
|
17
|
+
/**
|
|
18
|
+
* A From address is only usable when it looks like a real mailbox: both parts
|
|
19
|
+
* present, neither is a known "could not parse" sentinel, and the host carries
|
|
20
|
+
* an actual domain (at least one dot). Detect this structurally so a fabricated
|
|
21
|
+
* string like `missing_mailbox@missing_domain` is never persisted as a sender.
|
|
22
|
+
*/
|
|
23
|
+
export const isParseableEmailAddress = (address) => {
|
|
24
|
+
if (!address)
|
|
25
|
+
return false;
|
|
26
|
+
const mailbox = address.mailbox?.trim();
|
|
27
|
+
const host = address.host?.trim();
|
|
28
|
+
if (!mailbox || !host)
|
|
29
|
+
return false;
|
|
30
|
+
if (mailbox === HOSTNET_MISSING_MAILBOX || host === HOSTNET_MISSING_DOMAIN) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return host.includes(".");
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* What a sighting of an address in this mailbox is worth. Junk withholds the
|
|
37
|
+
* sender from autocomplete (#822), Trash decides nothing either way, and
|
|
38
|
+
* anywhere else is a correspondent.
|
|
39
|
+
*
|
|
40
|
+
* Decided by mailbox IDENTITY against the account's resolved roles, not by the
|
|
41
|
+
* folder's flag and name: a role belongs to exactly one folder (RFC 032), and
|
|
42
|
+
* the folder it belongs to is whichever one the user appointed. Reading the
|
|
43
|
+
* name here meant a second folder called `Spam` also withheld its senders,
|
|
44
|
+
* while the folder the user actually appointed harvested every spammer in it as
|
|
45
|
+
* a correspondent (#837).
|
|
46
|
+
*/
|
|
47
|
+
export const addressSightingIn = (mailboxId, roles) => {
|
|
48
|
+
if (roles.junkMailboxId !== null && mailboxId === roles.junkMailboxId) {
|
|
49
|
+
return "junk";
|
|
50
|
+
}
|
|
51
|
+
if (roles.trashMailboxId !== null && mailboxId === roles.trashMailboxId) {
|
|
52
|
+
return "discarded";
|
|
53
|
+
}
|
|
54
|
+
return "correspondent";
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The name an envelope carries for an address, as it should be stored: any
|
|
58
|
+
* claim to be some other address removed (issue #826), the rest of the name
|
|
59
|
+
* kept. Refused wherever a name lands — the address book, the envelope the
|
|
60
|
+
* message header renders, and the sender label on the thread row.
|
|
61
|
+
*/
|
|
62
|
+
export const harvestedDisplayName = (address) => {
|
|
63
|
+
if (!address?.name)
|
|
64
|
+
return "";
|
|
65
|
+
return storedDisplayName(address.name, `${address.mailbox}@${address.host}`);
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Parse an external `Date:` header into an epoch-millisecond integer.
|
|
69
|
+
*
|
|
70
|
+
* The IMAP envelope `date` is a raw RFC 2822 header copied verbatim from the
|
|
71
|
+
* message. It can be missing, malformed, or in a format `Date` cannot parse —
|
|
72
|
+
* `new Date(raw).getTime()` then yields `NaN`. `NaN` is not a valid integer
|
|
73
|
+
* and ElectroDB rejects it, which previously threw on the envelope upsert and
|
|
74
|
+
* (because the batch aborted on the first rejection) stalled the whole mailbox.
|
|
75
|
+
*
|
|
76
|
+
* When the header is unparseable we fall back to `fallbackMs` — the IMAP
|
|
77
|
+
* server's own INTERNALDATE receive time, always a valid integer. The raw
|
|
78
|
+
* header is preserved separately in `dateRaw`, so nothing is lost.
|
|
79
|
+
*/
|
|
80
|
+
export const parseHeaderDate = (raw, fallbackMs) => {
|
|
81
|
+
if (raw !== undefined && raw !== "") {
|
|
82
|
+
const parsed = new Date(raw).getTime();
|
|
83
|
+
if (Number.isFinite(parsed)) {
|
|
84
|
+
return { value: parsed, usedFallback: false };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { value: fallbackMs, usedFallback: true };
|
|
88
|
+
};
|
|
89
|
+
const noopLogger = {
|
|
90
|
+
info: () => { },
|
|
91
|
+
warn: () => { },
|
|
92
|
+
error: () => { },
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* The UIDs of this batch's undecided sightings (#1146). They join `failedUids`
|
|
96
|
+
* for the same reason a save that threw does: the watermark is the only thing
|
|
97
|
+
* that brings a UID back, and a sighting the round could not settle has to come
|
|
98
|
+
* back. A round that keeps failing to settle one trips the stalled-cursor alert,
|
|
99
|
+
* which is the surface for a source folder that has become unaskable.
|
|
100
|
+
*/
|
|
101
|
+
const unresolvedSightingUids = (outcomes) => outcomes.flatMap((outcome) => outcome.kind === "saved" &&
|
|
102
|
+
outcome.result !== null &&
|
|
103
|
+
outcome.result.unresolvedSighting
|
|
104
|
+
? [outcome.uid]
|
|
105
|
+
: []);
|
|
106
|
+
const syncedUidsOf = (outcomes) => outcomes.flatMap((outcome) => outcome.kind === "saved" && !outcome.result?.unresolvedSighting
|
|
107
|
+
? [outcome.uid]
|
|
108
|
+
: []);
|
|
109
|
+
const saveFailedUidsOf = (outcomes) => outcomes.flatMap((outcome) => outcome.kind === "failed" ? [outcome.uid] : []);
|
|
110
|
+
const envelopeLessUidsOf = (messages) => messages.flatMap((msg) => (hasEnvelope(msg) ? [] : [msg.uid]));
|
|
111
|
+
const emptySyncResult = () => ({
|
|
112
|
+
syncedCount: 0,
|
|
113
|
+
syncedMessageIds: [],
|
|
114
|
+
syncedMessages: [],
|
|
115
|
+
hasMore: false,
|
|
116
|
+
remainingCount: 0,
|
|
117
|
+
cursorStalled: false,
|
|
118
|
+
});
|
|
119
|
+
/**
|
|
120
|
+
* Pick the UIDs a full-enumeration round should sync, newest first.
|
|
121
|
+
*
|
|
122
|
+
* 1. New messages: UIDs above the forward watermark.
|
|
123
|
+
* 2. Backfill: UIDs below the lowest one synced so far.
|
|
124
|
+
* 3. A mailbox with no watermarks at all syncs everything.
|
|
125
|
+
*/
|
|
126
|
+
export const selectUidsToSync = (allUids, lastSyncUid, highWaterMarkUid) => {
|
|
127
|
+
const newUids = allUids.filter((uid) => uid > highWaterMarkUid);
|
|
128
|
+
const backfillUids = lastSyncUid > 1 ? allUids.filter((uid) => uid < lastSyncUid) : [];
|
|
129
|
+
const isFreshSync = highWaterMarkUid === 0 && lastSyncUid === 0;
|
|
130
|
+
const uidsToSync = isFreshSync ? [...allUids] : [...newUids, ...backfillUids];
|
|
131
|
+
return uidsToSync.sort((a, b) => b - a);
|
|
132
|
+
};
|
|
133
|
+
/** A FETCH row that names a message: everything downstream needs the envelope. */
|
|
134
|
+
const hasEnvelope = (msg) => msg.envelope !== undefined;
|
|
135
|
+
/**
|
|
136
|
+
* The row identity a sighting resolves to. Folder-independent whenever the
|
|
137
|
+
* message carries a usable `Message-ID` header, which is what lets the same
|
|
138
|
+
* mail in two of an account's folders meet on one row.
|
|
139
|
+
*/
|
|
140
|
+
const messageIdForSighting = (accountId, mailboxId, msg) => deriveMessageIdFromSource(accountId, {
|
|
141
|
+
messageId: msg.envelope.messageId,
|
|
142
|
+
uid: msg.uid,
|
|
143
|
+
mailboxId,
|
|
144
|
+
date: msg.envelope.date,
|
|
145
|
+
subject: msg.envelope.subject,
|
|
146
|
+
fromMailbox: msg.envelope.from?.[0]?.mailbox,
|
|
147
|
+
fromHost: msg.envelope.from?.[0]?.host,
|
|
148
|
+
});
|
|
149
|
+
export class MessageSyncService {
|
|
150
|
+
connectionFactory;
|
|
151
|
+
mailboxService;
|
|
152
|
+
mailboxSpecialUseService;
|
|
153
|
+
messageService;
|
|
154
|
+
envelopeService;
|
|
155
|
+
addressService;
|
|
156
|
+
threadMessageService;
|
|
157
|
+
syncWarningService;
|
|
158
|
+
flagPushMarkerService;
|
|
159
|
+
messageFlagService;
|
|
160
|
+
quarantineService;
|
|
161
|
+
log;
|
|
162
|
+
constructor(connectionFactory, mailboxService,
|
|
163
|
+
/**
|
|
164
|
+
* The account’s folder-role map. Required, not optional: which folder
|
|
165
|
+
* holds the Junk role decides whether a sighting withholds its sender
|
|
166
|
+
* from autocomplete, and a construction without it would quietly harvest
|
|
167
|
+
* spammers as correspondents.
|
|
168
|
+
*/
|
|
169
|
+
mailboxSpecialUseService, messageService, envelopeService, addressService, threadMessageService, syncWarningService, logger,
|
|
170
|
+
/**
|
|
171
|
+
* Pending outbound flag-push markers (#1273). Supplied, an inbound
|
|
172
|
+
* metadata change never overwrites a local flip that IMAP has not been
|
|
173
|
+
* told about yet; omitted, the server always wins.
|
|
174
|
+
*/
|
|
175
|
+
flagPushMarkerService,
|
|
176
|
+
/**
|
|
177
|
+
* The canonical flag record. Supplied, an inbound metadata change lands
|
|
178
|
+
* on the same record the outbound flip path reads, so a user's next
|
|
179
|
+
* flip is never dismissed as redundant.
|
|
180
|
+
*/
|
|
181
|
+
messageFlagService,
|
|
182
|
+
/**
|
|
183
|
+
* The set of messages already set aside (issue #72). Message sync never
|
|
184
|
+
* writes a record — only the body path can attribute a failure to the
|
|
185
|
+
* message — but it reads the set so a uid the body path quarantined is
|
|
186
|
+
* not re-fetched on every round.
|
|
187
|
+
*/
|
|
188
|
+
quarantineService) {
|
|
189
|
+
this.connectionFactory = connectionFactory;
|
|
190
|
+
this.mailboxService = mailboxService;
|
|
191
|
+
this.mailboxSpecialUseService = mailboxSpecialUseService;
|
|
192
|
+
this.messageService = messageService;
|
|
193
|
+
this.envelopeService = envelopeService;
|
|
194
|
+
this.addressService = addressService;
|
|
195
|
+
this.threadMessageService = threadMessageService;
|
|
196
|
+
this.syncWarningService = syncWarningService;
|
|
197
|
+
this.flagPushMarkerService = flagPushMarkerService;
|
|
198
|
+
this.messageFlagService = messageFlagService;
|
|
199
|
+
this.quarantineService = quarantineService;
|
|
200
|
+
this.log = logger ?? noopLogger;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Sync ONE batch of messages for a mailbox using newest-first strategy.
|
|
204
|
+
*
|
|
205
|
+
* Uses dual-watermark tracking:
|
|
206
|
+
* - highWaterMarkUid: highest UID ever seen (detects new messages)
|
|
207
|
+
* - lastSyncUid: lowest UID processed (tracks backfill progress)
|
|
208
|
+
*
|
|
209
|
+
* Returns hasMore=true if there are more messages to sync. The caller
|
|
210
|
+
* should re-enqueue another sync event to continue processing.
|
|
211
|
+
*
|
|
212
|
+
* @param mailboxId - The database mailbox ID
|
|
213
|
+
* @param accountId - The account ID (scopes message/thread identity)
|
|
214
|
+
* @param accountConfigId - The account config ID (used for address linking)
|
|
215
|
+
* @param batchSize - Number of messages to process per batch
|
|
216
|
+
*/
|
|
217
|
+
async syncMessages(mailboxId, accountId, accountConfigId, batchSize = 50) {
|
|
218
|
+
const mailbox = await this.mailboxService.get(accountId, mailboxId);
|
|
219
|
+
const mailboxPath = mailbox.fullPath;
|
|
220
|
+
// A mailbox whose cursor is already invalid (or a rebuild that crashed
|
|
221
|
+
// mid-way, #1272) never falls through to the normal watermark-based sync
|
|
222
|
+
// below — stored UIDs on that axis are not trustworthy. The rebuild is a
|
|
223
|
+
// variant of sync, not a special wipe path, so it runs here under the
|
|
224
|
+
// same mailbox lock the caller already holds.
|
|
225
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
226
|
+
return this.rebuildCursor(mailbox, accountId, accountConfigId, mailboxId);
|
|
227
|
+
}
|
|
228
|
+
const lastSyncUid = mailbox.lastSyncUid || 0;
|
|
229
|
+
const highWaterMarkUid = mailbox.highWaterMarkUid || 0;
|
|
230
|
+
const connection = this.connectionFactory.getConnection();
|
|
231
|
+
const box = await connection.openBox(mailboxPath);
|
|
232
|
+
const status = await connection.getMailboxStatus(mailboxPath);
|
|
233
|
+
// Detection: the served UIDVALIDITY may have changed since it was last
|
|
234
|
+
// stored, even though this mailbox was `normal` a moment ago. Trip the
|
|
235
|
+
// cursor and pause — every stored watermark, the mod-sequence included,
|
|
236
|
+
// is meaningless on the new axis, so nothing below may be acted on this
|
|
237
|
+
// round (epic #1281 invariants 3 and 5). The rebuild that follows is
|
|
238
|
+
// where the mod-sequence is reseeded from the new axis.
|
|
239
|
+
const cursorCheck = await guardMailboxCursor({ mailboxService: this.mailboxService }, accountId, mailbox, box.uidvalidity);
|
|
240
|
+
if (!cursorCheck.ok) {
|
|
241
|
+
this.log.warn({ mailboxId, mailboxPath, cursorState: cursorCheck.state }, "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound sync this round");
|
|
242
|
+
return emptySyncResult();
|
|
243
|
+
}
|
|
244
|
+
if (await this.restoreMissingStructure(mailbox, accountId, accountConfigId, batchSize)) {
|
|
245
|
+
await connection.openBox(mailboxPath);
|
|
246
|
+
}
|
|
247
|
+
const cursor = parseChangeCursor(mailbox.highestModseq);
|
|
248
|
+
if (hasChangeCursor(cursor) && connection.supportsCondstore()) {
|
|
249
|
+
return this.syncChangedSince({
|
|
250
|
+
mailbox,
|
|
251
|
+
accountId,
|
|
252
|
+
accountConfigId,
|
|
253
|
+
cursor,
|
|
254
|
+
box,
|
|
255
|
+
status,
|
|
256
|
+
batchSize,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
// One read per round, not per message (issue #72). The list is small by
|
|
260
|
+
// design — a growing one is a bug being reported, not a page to paginate.
|
|
261
|
+
const quarantined = await this.quarantineService?.load(accountConfigId);
|
|
262
|
+
const allUids = await connection.search(["ALL"]);
|
|
263
|
+
const uids = selectUidsToSync(allUids, lastSyncUid, highWaterMarkUid);
|
|
264
|
+
const unseenCount = status.unseen;
|
|
265
|
+
const deletedCount = status.deletedCount;
|
|
266
|
+
if (uids.length === 0) {
|
|
267
|
+
await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
|
|
268
|
+
failedUids: [],
|
|
269
|
+
syncedUids: [],
|
|
270
|
+
serverUids: { kind: "Listed", uids: allUids },
|
|
271
|
+
});
|
|
272
|
+
// Nothing left to enumerate: the folder is fully covered on this
|
|
273
|
+
// UIDVALIDITY axis, which is the one moment a mod-sequence watermark
|
|
274
|
+
// can be seeded without hiding unsynced history behind it. From the
|
|
275
|
+
// next round on, this mailbox takes the CHANGEDSINCE path above.
|
|
276
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
277
|
+
lastMessageSyncAt: Date.now(),
|
|
278
|
+
uidValidity: box.uidvalidity,
|
|
279
|
+
messageCount: status.messages,
|
|
280
|
+
highestModseq: status.highestModseq,
|
|
281
|
+
unseenCount,
|
|
282
|
+
deletedCount,
|
|
283
|
+
});
|
|
284
|
+
this.log.info({
|
|
285
|
+
mailboxId,
|
|
286
|
+
mailboxPath,
|
|
287
|
+
total: 0,
|
|
288
|
+
messageCount: status.messages,
|
|
289
|
+
unseenCount,
|
|
290
|
+
highestModseq: status.highestModseq,
|
|
291
|
+
}, "No new messages to sync");
|
|
292
|
+
return emptySyncResult();
|
|
293
|
+
}
|
|
294
|
+
const totalBatches = Math.ceil(uids.length / batchSize);
|
|
295
|
+
this.log.info({ mailboxId, mailboxPath, total: uids.length, batches: totalBatches }, "Starting message sync batch (newest first)");
|
|
296
|
+
// Process only the first batch
|
|
297
|
+
const batchUids = uids.slice(0, batchSize);
|
|
298
|
+
// A quarantined UID is not fetched again, but it stays in `batchUids` so
|
|
299
|
+
// the watermark still advances over it. Filtering it out of the selection
|
|
300
|
+
// instead would hold the watermark below a message that is already
|
|
301
|
+
// durably resolved, which is the stall by another route.
|
|
302
|
+
const fetchUids = batchUids.filter((uid) => !quarantined?.has(mailboxId, box.uidvalidity, uid));
|
|
303
|
+
const messages = fetchUids.length > 0 ? await this.fetchMessageBatch(fetchUids) : [];
|
|
304
|
+
// A UID the round could not act on. Two shapes reach here and neither is
|
|
305
|
+
// the message's fault: the FETCH returned no row at all (the connection
|
|
306
|
+
// layer drops rows imapflow yields without a usable UID or INTERNALDATE,
|
|
307
|
+
// #408, and a message can be expunged between the SEARCH and the FETCH),
|
|
308
|
+
// or it returned a row carrying no ENVELOPE, which names no message and
|
|
309
|
+
// is the same client-side glitch one field further in.
|
|
310
|
+
//
|
|
311
|
+
// They join `failedUids` rather than merely being left out of the batch.
|
|
312
|
+
// Absence alone does not hold a watermark: `advanceUidWatermarks` takes
|
|
313
|
+
// the MAX of what was applied, so a gap in the middle of a batch is
|
|
314
|
+
// stepped straight over — [23, 22, 21] with 22 missing still advances to
|
|
315
|
+
// 23 and loses 22 for good. A failure is the one thing a watermark is
|
|
316
|
+
// built to stop below.
|
|
317
|
+
const applicable = messages.filter(hasEnvelope);
|
|
318
|
+
const unusableUids = batchUids.filter((uid) => !applicable.some((msg) => msg.uid === uid) &&
|
|
319
|
+
!quarantined?.has(mailboxId, box.uidvalidity, uid));
|
|
320
|
+
if (unusableUids.length > 0) {
|
|
321
|
+
this.log.warn({ mailboxId, mailboxPath, unusableUids }, "FETCH returned no usable row for some requested UIDs; holding the watermark below them");
|
|
322
|
+
}
|
|
323
|
+
// Process messages in parallel with concurrency limit. `stopOnError` stays
|
|
324
|
+
// at its default — but each message is saved through `trySaveMessage`,
|
|
325
|
+
// which catches its own error and reports a `failed` outcome instead of
|
|
326
|
+
// rejecting. So one bad message can no longer abort the whole batch (the
|
|
327
|
+
// poison pill that previously froze the mailbox, #817).
|
|
328
|
+
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
329
|
+
const departures = await this.probeDepartures(mailbox, accountId, applicable);
|
|
330
|
+
const outcomes = await pMap(applicable, (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles, departures), { concurrency: MESSAGE_SAVE_CONCURRENCY });
|
|
331
|
+
// Body-sync set: only rows created or owned by THIS mailbox. A residual
|
|
332
|
+
// cross-mailbox collision (same deterministic messageId already owned by a
|
|
333
|
+
// different mailbox) must not push a foreign-owned messageId into
|
|
334
|
+
// syncedMessageIds, or body-sync would FETCH against the wrong mailbox's UID.
|
|
335
|
+
const ownedResults = outcomes.flatMap((o) => o.kind === "saved" && o.result !== null && o.result.owned
|
|
336
|
+
? [o.result]
|
|
337
|
+
: []);
|
|
338
|
+
const syncedMessages = ownedResults.map(({ messageId, uid }) => ({ messageId, uid }));
|
|
339
|
+
const syncedMessageIds = syncedMessages.map((m) => m.messageId);
|
|
340
|
+
// UIDs whose save threw. They must stay inside the next cycle's fetch
|
|
341
|
+
// window, so the watermark may not advance past them (no silent loss).
|
|
342
|
+
const saveFailedUids = outcomes.flatMap((o) => o.kind === "failed" ? [o.uid] : []);
|
|
343
|
+
if (saveFailedUids.length > 0) {
|
|
344
|
+
this.log.warn({ mailboxId, mailboxPath, failedUids: saveFailedUids }, "Some messages failed to save; holding watermark below them for retry");
|
|
345
|
+
}
|
|
346
|
+
const unresolvedUids = unresolvedSightingUids(outcomes);
|
|
347
|
+
if (unresolvedUids.length > 0) {
|
|
348
|
+
this.log.warn({ mailboxId, mailboxPath, unresolvedUids }, "Some sightings reached no verdict about the folder their row points at; holding the watermark below them");
|
|
349
|
+
}
|
|
350
|
+
const failedUids = new Set([
|
|
351
|
+
...saveFailedUids,
|
|
352
|
+
...unusableUids,
|
|
353
|
+
...unresolvedUids,
|
|
354
|
+
]);
|
|
355
|
+
// Watermarks advance over every SUCCESSFULLY-consumed UID in the batch,
|
|
356
|
+
// independent of ownership. `selectUidsToSync` reselects work purely by UID
|
|
357
|
+
// vs watermark (there is no per-UID processed set), so a foreign-owned UID
|
|
358
|
+
// that did not advance the watermark would be re-fetched every cycle
|
|
359
|
+
// forever. The same Message-ID legitimately appears in several of one
|
|
360
|
+
// account's mailboxes (Gmail All Mail + INBOX/labels), so cross-mailbox
|
|
361
|
+
// conflicts are routine; excluding them from body-sync is correct, stalling
|
|
362
|
+
// forward sync is not. Failures are what a watermark may never pass —
|
|
363
|
+
// see `advanceUidWatermarks`.
|
|
364
|
+
const { highWaterMarkUid: newHighWaterMark, lastSyncUid: newLastSyncUid } = advanceUidWatermarks({
|
|
365
|
+
batchUids,
|
|
366
|
+
failedUids,
|
|
367
|
+
lastSyncUid,
|
|
368
|
+
highWaterMarkUid,
|
|
369
|
+
});
|
|
370
|
+
const remainingCount = uids.length - batchUids.length;
|
|
371
|
+
const hasMore = remainingCount > 0;
|
|
372
|
+
// The mod-sequence watermark is seeded only by a round that leaves no
|
|
373
|
+
// enumeration work behind and lost no message to a failed save. Seeding
|
|
374
|
+
// it earlier would switch the mailbox to CHANGEDSINCE while UIDs it has
|
|
375
|
+
// never fetched still sit below the watermark, and those messages would
|
|
376
|
+
// never be discovered. A UID the FETCH returned nothing usable for is in
|
|
377
|
+
// `failedUids` too: the round did not finish with it either.
|
|
378
|
+
const enumerationComplete = !hasMore && failedUids.size === 0;
|
|
379
|
+
await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
|
|
380
|
+
failedUids: [...saveFailedUids, ...envelopeLessUidsOf(messages)],
|
|
381
|
+
syncedUids: syncedUidsOf(outcomes),
|
|
382
|
+
serverUids: { kind: "Listed", uids: allUids },
|
|
383
|
+
});
|
|
384
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
385
|
+
lastSyncUid: newLastSyncUid,
|
|
386
|
+
highWaterMarkUid: newHighWaterMark,
|
|
387
|
+
lastMessageSyncAt: Date.now(),
|
|
388
|
+
uidValidity: box.uidvalidity,
|
|
389
|
+
messageCount: status.messages,
|
|
390
|
+
...(enumerationComplete ? { highestModseq: status.highestModseq } : {}),
|
|
391
|
+
unseenCount,
|
|
392
|
+
deletedCount,
|
|
393
|
+
});
|
|
394
|
+
// Same stall condition as the CHANGEDSINCE round, on the UID axis: work
|
|
395
|
+
// selected, nothing moved, and the next round will select exactly the
|
|
396
|
+
// same work. No error surfaces on its own — every failure here is caught
|
|
397
|
+
// and held back — so this is the only signal that the mailbox is stuck.
|
|
398
|
+
const stalled = newHighWaterMark === highWaterMarkUid && newLastSyncUid === lastSyncUid;
|
|
399
|
+
if (stalled) {
|
|
400
|
+
this.log.error({
|
|
401
|
+
alert: "message_sync_cursor_stalled",
|
|
402
|
+
mailboxId,
|
|
403
|
+
mailboxPath,
|
|
404
|
+
accountId,
|
|
405
|
+
pendingUids: uids.length,
|
|
406
|
+
failedUids: [...failedUids],
|
|
407
|
+
}, "Message sync watermarks did not advance while UIDs were pending; this mailbox stops syncing until they do");
|
|
408
|
+
}
|
|
409
|
+
this.log.info({
|
|
410
|
+
batch: 1,
|
|
411
|
+
totalBatches,
|
|
412
|
+
batchSize: messages.length,
|
|
413
|
+
synced: syncedMessageIds.length,
|
|
414
|
+
total: uids.length,
|
|
415
|
+
remaining: remainingCount,
|
|
416
|
+
hasMore,
|
|
417
|
+
highWaterMarkUid: newHighWaterMark,
|
|
418
|
+
lastSyncUid: newLastSyncUid,
|
|
419
|
+
}, "Batch complete");
|
|
420
|
+
return {
|
|
421
|
+
syncedCount: syncedMessageIds.length,
|
|
422
|
+
syncedMessageIds,
|
|
423
|
+
syncedMessages,
|
|
424
|
+
hasMore,
|
|
425
|
+
remainingCount,
|
|
426
|
+
cursorStalled: stalled,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Re-key a mailbox's stored UIDs against a new UIDVALIDITY axis (#1272).
|
|
431
|
+
*
|
|
432
|
+
* One envelope-level pass (UID + Message-ID + INTERNALDATE, no body
|
|
433
|
+
* fetches — {@link matchCursorRebuild}) matched against the rows already
|
|
434
|
+
* stored for this mailbox:
|
|
435
|
+
* - Match → rewrite the row's UID mapping in place; bodies and threads are
|
|
436
|
+
* untouched.
|
|
437
|
+
* - Server message with no row → normal new-message sync (the same
|
|
438
|
+
* `trySaveMessage` pipeline the regular batch sync uses).
|
|
439
|
+
* - Row with no counterpart → expunged; removed through `deleteIfPlacement`
|
|
440
|
+
* on the uid the rebuild read (#1283 — the exact same "gone upstream"
|
|
441
|
+
* outcome as a body-sync retry exhaustion finding a stale row), so a row
|
|
442
|
+
* a move re-pointed in the meantime is left alone.
|
|
443
|
+
*
|
|
444
|
+
* Idempotent by construction: re-entering mid-rebuild (crash recovery —
|
|
445
|
+
* the mailbox was left `rebuilding`) simply redoes the same match/rewrite
|
|
446
|
+
* pass, which converges on the same result. `cursorState` is stamped
|
|
447
|
+
* `rebuilding` before any write and only cleared to `normal` after the
|
|
448
|
+
* watermarks are rebuilt, so a crash anywhere in between leaves the
|
|
449
|
+
* mailbox paused rather than falling back to the stale axis.
|
|
450
|
+
*/
|
|
451
|
+
async rebuildCursor(mailbox, accountId, accountConfigId, mailboxId) {
|
|
452
|
+
const mailboxPath = mailbox.fullPath;
|
|
453
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
454
|
+
cursorState: MailboxCursorState.rebuilding,
|
|
455
|
+
});
|
|
456
|
+
const connection = this.connectionFactory.getConnection();
|
|
457
|
+
const box = await connection.openBox(mailboxPath);
|
|
458
|
+
// Read before the pass, never after: a message arriving while the
|
|
459
|
+
// rebuild runs is absent from the snapshot below, and a HIGHESTMODSEQ
|
|
460
|
+
// read afterwards would already sit above that arrival's mod-sequence —
|
|
461
|
+
// seeding it would close the mailbox over a message it never stored.
|
|
462
|
+
const status = await connection.getMailboxStatus(mailboxPath);
|
|
463
|
+
const allUids = await connection.search(["ALL"]);
|
|
464
|
+
const snapshots = await connection.fetchEnvelopeSnapshots(allUids);
|
|
465
|
+
const serverSnapshots = snapshots.map((s) => ({
|
|
466
|
+
uid: s.uid,
|
|
467
|
+
messageId: s.messageId,
|
|
468
|
+
internalDate: s.internalDate.getTime(),
|
|
469
|
+
}));
|
|
470
|
+
const existingRows = await this.listExistingCursorRows(accountConfigId, mailboxId);
|
|
471
|
+
const { matched, newUids, staleMessageIds } = matchCursorRebuild(serverSnapshots, existingRows);
|
|
472
|
+
// Bounded concurrency (mirrors MESSAGE_SAVE_CONCURRENCY below) — a
|
|
473
|
+
// sequential loop over a large mailbox's full match set risked the
|
|
474
|
+
// Lambda timeout on its own, independent of the fetch-size question
|
|
475
|
+
// (#1272 review, non-blocking finding).
|
|
476
|
+
await pMap(matched, async ({ messageId, newUid, threadMessage }) => {
|
|
477
|
+
await this.messageService.updateUid(messageId, newUid, mailboxId);
|
|
478
|
+
// Rewrite the denormalized ThreadMessage.uid alongside Message.uid —
|
|
479
|
+
// a normal move keeps both in sync (see buildThreadMessageMoveUpdate
|
|
480
|
+
// in message-move.ts), and the rebuild must too, or a resumed
|
|
481
|
+
// rebuild re-emits a no-op rewrite forever (listExistingCursorRows
|
|
482
|
+
// reads uid from ThreadMessage) and any reader of the list
|
|
483
|
+
// projection sees a stale UID.
|
|
484
|
+
if (threadMessage) {
|
|
485
|
+
await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, { uid: newUid }, {
|
|
486
|
+
composites: {
|
|
487
|
+
sentDate: threadMessage.sentDate,
|
|
488
|
+
mailboxId: threadMessage.mailboxId,
|
|
489
|
+
isRead: threadMessage.isRead,
|
|
490
|
+
isDeleted: threadMessage.isDeleted,
|
|
491
|
+
hasStars: threadMessage.hasStars,
|
|
492
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}, { concurrency: MESSAGE_SAVE_CONCURRENCY });
|
|
497
|
+
const stale = new Set(staleMessageIds);
|
|
498
|
+
await pMap(existingRows.filter((row) => stale.has(row.messageId)), (row) => this.messageService.deleteIfPlacement(row.messageId, {
|
|
499
|
+
mailboxId,
|
|
500
|
+
uid: row.uid,
|
|
501
|
+
}), { concurrency: MESSAGE_SAVE_CONCURRENCY });
|
|
502
|
+
const newMessages = newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
|
|
503
|
+
const applicable = newMessages.filter(hasEnvelope);
|
|
504
|
+
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
505
|
+
const departures = await this.probeDepartures(mailbox, accountId, applicable);
|
|
506
|
+
const outcomes = await pMap(applicable, (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles, departures), { concurrency: MESSAGE_SAVE_CONCURRENCY });
|
|
507
|
+
const syncedMessages = outcomes.flatMap((o) => o.kind === "saved" && o.result !== null && o.result.owned
|
|
508
|
+
? [{ messageId: o.result.messageId, uid: o.result.uid }]
|
|
509
|
+
: []);
|
|
510
|
+
const serverUids = serverSnapshots.map((s) => s.uid);
|
|
511
|
+
// A UID this pass could not account for, in any of the three ways it can
|
|
512
|
+
// go missing: the snapshot FETCH never returned a row for it, the
|
|
513
|
+
// message FETCH never returned one, or the row it returned carried no
|
|
514
|
+
// ENVELOPE. None of the three is the message's fault and none can be
|
|
515
|
+
// quarantined, so each has to keep the UID selectable.
|
|
516
|
+
//
|
|
517
|
+
// This matters more here than on the enumeration path, not less. The
|
|
518
|
+
// covered region is computed from `serverUids` rather than from what was
|
|
519
|
+
// applied, so a UID missing anywhere inside its span is silently inside
|
|
520
|
+
// it; and this round seeds the mod-sequence and returns the mailbox to
|
|
521
|
+
// `normal`, so the next round takes CHANGEDSINCE, which never
|
|
522
|
+
// enumerates. A UID lost here is lost for good.
|
|
523
|
+
const savedUids = new Set(applicable.map((msg) => msg.uid));
|
|
524
|
+
const snapshotUids = new Set(serverUids);
|
|
525
|
+
const unusableUids = [
|
|
526
|
+
...allUids.filter((uid) => !snapshotUids.has(uid)),
|
|
527
|
+
...newUids.filter((uid) => !savedUids.has(uid)),
|
|
528
|
+
];
|
|
529
|
+
if (unusableUids.length > 0) {
|
|
530
|
+
this.log.warn({ mailboxId, mailboxPath, unusableUids }, "Cursor rebuild could not account for some UIDs; holding the watermark below them");
|
|
531
|
+
}
|
|
532
|
+
// A new message whose save threw must stay selectable, so the forward
|
|
533
|
+
// watermark stops below it and every UID above it is re-enumerated next
|
|
534
|
+
// round. The mod-sequence seed is withheld entirely in that case: the
|
|
535
|
+
// old value is meaningless on this axis (RFC 7162 requires discarding
|
|
536
|
+
// it on a UIDVALIDITY change) and the new one would sit above the
|
|
537
|
+
// message that failed, so the mailbox goes back to enumeration until a
|
|
538
|
+
// clean round seeds it.
|
|
539
|
+
const failedUids = new Set([
|
|
540
|
+
...outcomes.flatMap((o) => (o.kind === "failed" ? [o.uid] : [])),
|
|
541
|
+
...unusableUids,
|
|
542
|
+
...unresolvedSightingUids(outcomes),
|
|
543
|
+
]);
|
|
544
|
+
const lowestFailure = failedUids.size
|
|
545
|
+
? Math.min(...failedUids)
|
|
546
|
+
: Number.POSITIVE_INFINITY;
|
|
547
|
+
const coveredUids = serverUids.filter((uid) => uid < lowestFailure);
|
|
548
|
+
await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
|
|
549
|
+
failedUids: [
|
|
550
|
+
...saveFailedUidsOf(outcomes),
|
|
551
|
+
...envelopeLessUidsOf(newMessages),
|
|
552
|
+
],
|
|
553
|
+
syncedUids: syncedUidsOf(outcomes),
|
|
554
|
+
serverUids: { kind: "Listed", uids: allUids },
|
|
555
|
+
});
|
|
556
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
557
|
+
cursorState: MailboxCursorState.normal,
|
|
558
|
+
uidValidity: box.uidvalidity,
|
|
559
|
+
highWaterMarkUid: coveredUids.length > 0 ? Math.max(...coveredUids) : 0,
|
|
560
|
+
lastSyncUid: serverUids.length > 0 ? Math.min(...serverUids) : 0,
|
|
561
|
+
highestModseq: failedUids.size === 0 ? status.highestModseq : "0",
|
|
562
|
+
lastMessageSyncAt: Date.now(),
|
|
563
|
+
messageCount: status.messages,
|
|
564
|
+
unseenCount: status.unseen,
|
|
565
|
+
deletedCount: status.deletedCount,
|
|
566
|
+
});
|
|
567
|
+
this.log.info({
|
|
568
|
+
mailboxId,
|
|
569
|
+
mailboxPath,
|
|
570
|
+
matched: matched.length,
|
|
571
|
+
newMessages: syncedMessages.length,
|
|
572
|
+
stale: staleMessageIds.length,
|
|
573
|
+
}, "Mailbox cursor rebuild complete; returned to normal");
|
|
574
|
+
return {
|
|
575
|
+
syncedCount: syncedMessages.length,
|
|
576
|
+
syncedMessageIds: syncedMessages.map((m) => m.messageId),
|
|
577
|
+
syncedMessages,
|
|
578
|
+
hasMore: false,
|
|
579
|
+
remainingCount: 0,
|
|
580
|
+
cursorStalled: false,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Page through every ThreadMessage row for this mailbox, projecting just
|
|
585
|
+
* the fields {@link matchCursorRebuild} needs. ThreadMessage (not
|
|
586
|
+
* Message) is the read source: it already denormalizes `messageIdHeader`
|
|
587
|
+
* and `internalDate` alongside `uid`, so this needs no per-row Envelope
|
|
588
|
+
* lookup.
|
|
589
|
+
*/
|
|
590
|
+
async listExistingCursorRows(accountConfigId, mailboxId) {
|
|
591
|
+
const rows = [];
|
|
592
|
+
let continuationToken;
|
|
593
|
+
do {
|
|
594
|
+
const result = await this.threadMessageService.listByMailbox(accountConfigId, mailboxId, {
|
|
595
|
+
continuationToken,
|
|
596
|
+
attributes: [
|
|
597
|
+
"messageId",
|
|
598
|
+
"messageIdHeader",
|
|
599
|
+
"internalDate",
|
|
600
|
+
"uid",
|
|
601
|
+
"accountConfigId",
|
|
602
|
+
"threadMessageId",
|
|
603
|
+
"sentDate",
|
|
604
|
+
"mailboxId",
|
|
605
|
+
"isRead",
|
|
606
|
+
"isDeleted",
|
|
607
|
+
"hasStars",
|
|
608
|
+
"hasAttachment",
|
|
609
|
+
],
|
|
610
|
+
});
|
|
611
|
+
for (const row of result.items) {
|
|
612
|
+
rows.push({
|
|
613
|
+
messageId: row.messageId,
|
|
614
|
+
messageIdHeader: row.messageIdHeader ?? "",
|
|
615
|
+
internalDate: row.internalDate,
|
|
616
|
+
uid: row.uid,
|
|
617
|
+
// Carried so a match can also rewrite ThreadMessage.uid (a normal
|
|
618
|
+
// move keeps both in sync — the rebuild must too, or #1271's
|
|
619
|
+
// push-time UID resolution can read a stale projection).
|
|
620
|
+
threadMessage: {
|
|
621
|
+
accountConfigId: row.accountConfigId,
|
|
622
|
+
threadMessageId: row.threadMessageId,
|
|
623
|
+
sentDate: row.sentDate,
|
|
624
|
+
mailboxId: row.mailboxId,
|
|
625
|
+
isRead: row.isRead,
|
|
626
|
+
isDeleted: row.isDeleted,
|
|
627
|
+
hasStars: row.hasStars,
|
|
628
|
+
hasAttachment: row.hasAttachment,
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
continuationToken = result.continuationToken;
|
|
633
|
+
} while (continuationToken);
|
|
634
|
+
return rows;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* One CHANGEDSINCE round (issue #20).
|
|
638
|
+
*
|
|
639
|
+
* A single `FETCH ... (CHANGEDSINCE <modseq>)` over the whole UID space
|
|
640
|
+
* returns both the messages that arrived and the messages whose metadata
|
|
641
|
+
* changed since the stored watermark, so a flag flipped on another client
|
|
642
|
+
* is picked up without enumerating the folder. Rows already present in
|
|
643
|
+
* this folder take the metadata path — the envelope, body structure and
|
|
644
|
+
* addresses are immutable, so re-writing them for a read-state change would
|
|
645
|
+
* be pure waste; everything else, a row another client moved here from a
|
|
646
|
+
* folder it points at included, takes the normal save pipeline (#1386).
|
|
647
|
+
*
|
|
648
|
+
* Expunges are invisible here (CONDSTORE without QRESYNC never reports
|
|
649
|
+
* them, RFC 7162 Section 3.1.2.1) — they remain the reconcile path's job.
|
|
650
|
+
*/
|
|
651
|
+
async syncChangedSince(params) {
|
|
652
|
+
const { mailbox, accountId, accountConfigId, cursor, box, status, batchSize, } = params;
|
|
653
|
+
const mailboxId = mailbox.mailboxId;
|
|
654
|
+
const mailboxPath = mailbox.fullPath;
|
|
655
|
+
const connection = this.connectionFactory.getConnection();
|
|
656
|
+
// Ask from the last COMPLETE mod-sequence, so a group left part-applied
|
|
657
|
+
// by an earlier round is served again in full; its applied members are
|
|
658
|
+
// then dropped by position, without a lookup.
|
|
659
|
+
const changed = await connection.fetchMessagesChangedSince(cursor.modseq);
|
|
660
|
+
const ordered = dropAppliedPrefix(orderByModseq(changed), cursor);
|
|
661
|
+
const batch = ordered.slice(0, batchSize);
|
|
662
|
+
// A quarantined UID stays in `batch`, so the cursor still advances over
|
|
663
|
+
// it; only the work of re-applying it is skipped.
|
|
664
|
+
const quarantined = await this.quarantineService?.load(accountConfigId);
|
|
665
|
+
const applicable = batch
|
|
666
|
+
.filter((msg) => !quarantined?.has(mailboxId, box.uidvalidity, msg.uid))
|
|
667
|
+
.filter(hasEnvelope);
|
|
668
|
+
// A change row carrying no ENVELOPE holds the cursor and is retried; it
|
|
669
|
+
// is never set aside. On this path the message is usually one already
|
|
670
|
+
// stored — it demonstrably had a sender, a date and a Message-ID when it
|
|
671
|
+
// was first saved — so an envelope-less row is the FETCH glitching
|
|
672
|
+
// (#408), not the message being defective. Quarantining it would filter
|
|
673
|
+
// that UID out of every later round, stopping its flag sync until a
|
|
674
|
+
// purge, and tell the user a message they can open and read "arrived
|
|
675
|
+
// without a sender". A transient glitch heals on the retry; a persistent
|
|
676
|
+
// one trips the stalled-cursor alert, which is what that alert is for.
|
|
677
|
+
const unusableUids = batch.flatMap((msg) => msg.envelope === undefined &&
|
|
678
|
+
!quarantined?.has(mailboxId, box.uidvalidity, msg.uid)
|
|
679
|
+
? [msg.uid]
|
|
680
|
+
: []);
|
|
681
|
+
if (unusableUids.length > 0) {
|
|
682
|
+
this.log.warn({ mailboxId, mailboxPath, unusableUids }, "Change rows carried no ENVELOPE; holding the sync cursor below them");
|
|
683
|
+
}
|
|
684
|
+
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
685
|
+
const departures = await this.probeDepartures(mailbox, accountId, applicable);
|
|
686
|
+
const outcomes = await pMap(applicable, (msg) => this.tryApplyChange(mailbox, accountId, accountConfigId, msg, roles, departures), { concurrency: MESSAGE_SAVE_CONCURRENCY });
|
|
687
|
+
const saveFailedUids = outcomes.flatMap((o) => o.kind === "failed" ? [o.uid] : []);
|
|
688
|
+
if (saveFailedUids.length > 0) {
|
|
689
|
+
this.log.warn({ mailboxId, mailboxPath, failedUids: saveFailedUids }, "Some changes failed to apply; holding the sync cursor below them for retry");
|
|
690
|
+
}
|
|
691
|
+
const unresolvedUids = unresolvedSightingUids(outcomes);
|
|
692
|
+
if (unresolvedUids.length > 0) {
|
|
693
|
+
this.log.warn({ mailboxId, mailboxPath, unresolvedUids }, "Some sightings reached no verdict about the folder their row points at; holding the sync cursor below them");
|
|
694
|
+
}
|
|
695
|
+
const failedUids = new Set([
|
|
696
|
+
...saveFailedUids,
|
|
697
|
+
...unusableUids,
|
|
698
|
+
...unresolvedUids,
|
|
699
|
+
]);
|
|
700
|
+
// Body sync only concerns messages this round created — a metadata
|
|
701
|
+
// change has no new body to fetch.
|
|
702
|
+
const syncedMessages = outcomes.flatMap((o) => o.kind === "saved" && o.result !== null && o.result.owned
|
|
703
|
+
? [{ messageId: o.result.messageId, uid: o.result.uid }]
|
|
704
|
+
: []);
|
|
705
|
+
const syncedMessageIds = syncedMessages.map((m) => m.messageId);
|
|
706
|
+
const { cursor: nextCursor, hasMore } = advanceChangeCursor({
|
|
707
|
+
cursor,
|
|
708
|
+
serverModseq: parseModseq(status.highestModseq),
|
|
709
|
+
ordered,
|
|
710
|
+
batch,
|
|
711
|
+
failedUids,
|
|
712
|
+
});
|
|
713
|
+
const highestModseq = formatChangeCursor(nextCursor);
|
|
714
|
+
await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
|
|
715
|
+
failedUids: [...saveFailedUids, ...unusableUids],
|
|
716
|
+
syncedUids: syncedUidsOf(outcomes),
|
|
717
|
+
serverUids: {
|
|
718
|
+
kind: "OnRequest",
|
|
719
|
+
list: () => connection.search(["ALL"]),
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
// The UID watermark obeys the same clamp as the enumeration path even
|
|
723
|
+
// though the cursor governs retries here: a mailbox that later falls
|
|
724
|
+
// back to enumeration must not find a failed UID already behind its
|
|
725
|
+
// forward watermark.
|
|
726
|
+
const { highWaterMarkUid: newHighWaterMark } = advanceUidWatermarks({
|
|
727
|
+
batchUids: batch.map((msg) => msg.uid),
|
|
728
|
+
failedUids,
|
|
729
|
+
lastSyncUid: mailbox.lastSyncUid || 0,
|
|
730
|
+
highWaterMarkUid: mailbox.highWaterMarkUid || 0,
|
|
731
|
+
});
|
|
732
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
733
|
+
highestModseq,
|
|
734
|
+
highWaterMarkUid: newHighWaterMark,
|
|
735
|
+
lastMessageSyncAt: Date.now(),
|
|
736
|
+
uidValidity: box.uidvalidity,
|
|
737
|
+
messageCount: status.messages,
|
|
738
|
+
unseenCount: status.unseen,
|
|
739
|
+
deletedCount: status.deletedCount,
|
|
740
|
+
});
|
|
741
|
+
// A round that had work to do and moved nothing is stalled: the same
|
|
742
|
+
// fetch will return the same set forever, and the set only grows as the
|
|
743
|
+
// mailbox keeps changing. Nothing above this call fails, so nothing else
|
|
744
|
+
// would ever notice — the queue message is acked either way.
|
|
745
|
+
const stalled = ordered.length > 0 && highestModseq === mailbox.highestModseq;
|
|
746
|
+
if (stalled) {
|
|
747
|
+
this.log.error({
|
|
748
|
+
alert: "message_sync_cursor_stalled",
|
|
749
|
+
mailboxId,
|
|
750
|
+
mailboxPath,
|
|
751
|
+
accountId,
|
|
752
|
+
cursor: highestModseq,
|
|
753
|
+
pendingChanges: ordered.length,
|
|
754
|
+
failedUids: [...failedUids],
|
|
755
|
+
}, "Message sync cursor did not advance while changes were pending; this mailbox stops seeing changes until it does");
|
|
756
|
+
}
|
|
757
|
+
this.log.info({
|
|
758
|
+
mailboxId,
|
|
759
|
+
mailboxPath,
|
|
760
|
+
changed: ordered.length,
|
|
761
|
+
applied: batch.length,
|
|
762
|
+
created: syncedMessageIds.length,
|
|
763
|
+
fromCursor: formatChangeCursor(cursor),
|
|
764
|
+
cursor: highestModseq,
|
|
765
|
+
hasMore,
|
|
766
|
+
}, "CHANGEDSINCE round complete");
|
|
767
|
+
return {
|
|
768
|
+
syncedCount: syncedMessageIds.length,
|
|
769
|
+
syncedMessageIds,
|
|
770
|
+
syncedMessages,
|
|
771
|
+
hasMore,
|
|
772
|
+
remainingCount: ordered.length - batch.length,
|
|
773
|
+
cursorStalled: stalled,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
async settleHeaderWarnings(accountConfigId, mailbox, uidValidity, round) {
|
|
777
|
+
await this.syncWarningService.settle({
|
|
778
|
+
accountConfigId,
|
|
779
|
+
accountId: mailbox.accountId,
|
|
780
|
+
mailboxId: mailbox.mailboxId,
|
|
781
|
+
uidValidity,
|
|
782
|
+
}, SyncWarningStage.Header, round);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Apply one message from a CHANGEDSINCE result without ever rejecting —
|
|
786
|
+
* same contract as {@link trySaveMessage}, so a single unapplicable change
|
|
787
|
+
* holds the watermark back instead of failing the round.
|
|
788
|
+
*/
|
|
789
|
+
async tryApplyChange(mailbox, accountId, accountConfigId, msg, roles, departures) {
|
|
790
|
+
const mailboxId = mailbox.mailboxId;
|
|
791
|
+
return this.applyChange(mailbox, accountId, accountConfigId, msg, roles, departures)
|
|
792
|
+
.then((result) => ({ kind: "saved", uid: msg.uid, result }))
|
|
793
|
+
.catch((error) => {
|
|
794
|
+
this.log.warn({
|
|
795
|
+
mailboxId,
|
|
796
|
+
uid: msg.uid,
|
|
797
|
+
messageId: msg.envelope?.messageId,
|
|
798
|
+
error: error instanceof Error ? error.message : String(error),
|
|
799
|
+
}, "Failed to apply message change; will retry on next sync");
|
|
800
|
+
return { kind: "failed", uid: msg.uid };
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
async applyChange(mailbox, accountId, accountConfigId, msg, roles, departures) {
|
|
804
|
+
if (!hasEnvelope(msg))
|
|
805
|
+
return null;
|
|
806
|
+
const messageId = messageIdForSighting(accountId, mailbox.mailboxId, msg);
|
|
807
|
+
const existing = await this.threadMessageService.findByMessageId(accountConfigId, messageId);
|
|
808
|
+
if (!existing) {
|
|
809
|
+
return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles, departures);
|
|
810
|
+
}
|
|
811
|
+
await this.applyServerFlags(existing, msg.flags);
|
|
812
|
+
if (!departures.needsSave.has(messageId))
|
|
813
|
+
return null;
|
|
814
|
+
return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles, departures);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Bring a stored row's read and star state, and the $Junk/$NotJunk
|
|
818
|
+
* keywords on the canonical MessageFlag record, in line with the
|
|
819
|
+
* server's flags.
|
|
820
|
+
*
|
|
821
|
+
* A field with a pending outbound push is left alone: the user flipped it
|
|
822
|
+
* locally, IMAP has not been told yet, and the server's answer is
|
|
823
|
+
* therefore known-stale for that field. Writing it back would revert the
|
|
824
|
+
* flip in front of the user and then push the reverted value.
|
|
825
|
+
*/
|
|
826
|
+
async applyServerFlags(existing, flags) {
|
|
827
|
+
const isRead = flags.includes(MessageSystemFlag.Seen);
|
|
828
|
+
const hasStars = flags.includes(MessageSystemFlag.Flagged);
|
|
829
|
+
const updates = {};
|
|
830
|
+
if (existing.isRead !== isRead &&
|
|
831
|
+
!(await this.hasPendingPush(existing.messageId, MessageSystemFlag.Seen))) {
|
|
832
|
+
updates.isRead = isRead;
|
|
833
|
+
}
|
|
834
|
+
if (existing.hasStars !== hasStars &&
|
|
835
|
+
!(await this.hasPendingPush(existing.messageId, MessageSystemFlag.Flagged))) {
|
|
836
|
+
updates.hasStars = hasStars;
|
|
837
|
+
// `hasStars` is the boolean of record and `star` its presentation
|
|
838
|
+
// colour; the two may never disagree (#58). A star cleared upstream
|
|
839
|
+
// loses its colour; one set upstream takes the standard colour unless
|
|
840
|
+
// the row already carries a real one the user chose.
|
|
841
|
+
if (!hasStars) {
|
|
842
|
+
updates.star = StarColor.None;
|
|
843
|
+
}
|
|
844
|
+
else if (existing.star === undefined ||
|
|
845
|
+
existing.star === StarColor.None) {
|
|
846
|
+
updates.star = StarColor.Yellow;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
await this.mirrorKeywordFlag(existing.messageId, MessageKeywordFlag.Junk, flags);
|
|
850
|
+
await this.mirrorKeywordFlag(existing.messageId, MessageKeywordFlag.NotJunk, flags);
|
|
851
|
+
if (Object.keys(updates).length === 0)
|
|
852
|
+
return;
|
|
853
|
+
// MessageFlag is the canonical flag record — `FlagQueueService` reads it
|
|
854
|
+
// to decide whether a user's flip is redundant, and the API answers
|
|
855
|
+
// read/starred from it. Writing only the denormalized row would leave
|
|
856
|
+
// the two disagreeing, and the next local flip would be dismissed as
|
|
857
|
+
// already-in-state: a click that does nothing.
|
|
858
|
+
//
|
|
859
|
+
// Canonical record first, projection second — the same order the local
|
|
860
|
+
// flip path uses. A crash between the two leaves the pair inconsistent
|
|
861
|
+
// exactly as a crashed local flip would, and the round's watermark has
|
|
862
|
+
// not moved, so the next round re-fetches the message and re-applies
|
|
863
|
+
// both writes (each is idempotent).
|
|
864
|
+
if (updates.isRead !== undefined) {
|
|
865
|
+
await this.setMessageFlag(existing.messageId, MessageSystemFlag.Seen, updates.isRead);
|
|
866
|
+
}
|
|
867
|
+
if (updates.hasStars !== undefined) {
|
|
868
|
+
await this.setMessageFlag(existing.messageId, MessageSystemFlag.Flagged, updates.hasStars);
|
|
869
|
+
}
|
|
870
|
+
await this.threadMessageService.update(existing.accountConfigId, existing.threadMessageId, updates, {
|
|
871
|
+
// The CURRENT values of every sort-key attribute: ElectroDB uses
|
|
872
|
+
// them for the conditional check on the existing row and to
|
|
873
|
+
// recompute the new keys. Passing the new value here would fail
|
|
874
|
+
// that check and silently drop the update (see FlagQueueService).
|
|
875
|
+
composites: {
|
|
876
|
+
sentDate: existing.sentDate,
|
|
877
|
+
mailboxId: existing.mailboxId,
|
|
878
|
+
isRead: existing.isRead,
|
|
879
|
+
isDeleted: existing.isDeleted,
|
|
880
|
+
hasStars: existing.hasStars,
|
|
881
|
+
hasAttachment: existing.hasAttachment,
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
this.log.info({
|
|
885
|
+
messageId: existing.messageId,
|
|
886
|
+
threadMessageId: existing.threadMessageId,
|
|
887
|
+
...updates,
|
|
888
|
+
}, "Applied server flag state from CHANGEDSINCE");
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Set or clear one flag on the canonical record. Both repository calls are
|
|
892
|
+
* idempotent, so a re-applied change is a no-op rather than a conflict.
|
|
893
|
+
*/
|
|
894
|
+
async setMessageFlag(messageId, flagName, present) {
|
|
895
|
+
if (!this.messageFlagService)
|
|
896
|
+
return;
|
|
897
|
+
if (present) {
|
|
898
|
+
await this.messageFlagService.addFlag(messageId, flagName);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
await this.messageFlagService.removeFlag(messageId, flagName);
|
|
902
|
+
}
|
|
903
|
+
async hasPendingPush(messageId, flagName) {
|
|
904
|
+
if (!this.flagPushMarkerService)
|
|
905
|
+
return false;
|
|
906
|
+
const marker = await this.flagPushMarkerService.find(messageId, flagName);
|
|
907
|
+
return marker !== null;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Mirror one RFC 5788 keyword ($Junk / $NotJunk) from the server into the
|
|
911
|
+
* canonical MessageFlag record, guarded the same way as the system flags:
|
|
912
|
+
* a pending local push wins over the server's known-stale answer.
|
|
913
|
+
*
|
|
914
|
+
* This mailbox is never remit-only — Apple Mail or another IMAP client is
|
|
915
|
+
* routinely connected too — so the keyword is message state, nothing
|
|
916
|
+
* more. It never reaches the Address model: reading another client's
|
|
917
|
+
* keyword as a standing rule about the sender is the exact fight this
|
|
918
|
+
* mirror must not pick.
|
|
919
|
+
*/
|
|
920
|
+
async mirrorKeywordFlag(messageId, flagName, serverFlags) {
|
|
921
|
+
if (!this.messageFlagService)
|
|
922
|
+
return;
|
|
923
|
+
if (await this.hasPendingPush(messageId, flagName))
|
|
924
|
+
return;
|
|
925
|
+
const present = serverFlags.includes(flagName);
|
|
926
|
+
const hasFlag = await this.messageFlagService.hasFlag(messageId, flagName);
|
|
927
|
+
if (hasFlag === present)
|
|
928
|
+
return;
|
|
929
|
+
await this.setMessageFlag(messageId, flagName, present);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Messages the forwarded-message repair queued — their part rows dropped,
|
|
933
|
+
* the lost row only on the mail server — are fetched again by UID and saved
|
|
934
|
+
* through the same path as any other sighting, which writes their parts. No
|
|
935
|
+
* watermark moves: these UIDs are already behind them. Each leaves the queue
|
|
936
|
+
* once saved or once the server returns no usable row for it, whatever
|
|
937
|
+
* parts came back; only a failed save keeps it for the next round. Resolves
|
|
938
|
+
* `true` when it touched the connection, which the probe of departures may
|
|
939
|
+
* have left on another folder.
|
|
940
|
+
*/
|
|
941
|
+
async restoreMissingStructure(mailbox, accountId, accountConfigId, batchSize) {
|
|
942
|
+
const queued = await this.envelopeService.listQueuedStructureRefetches(mailbox.mailboxId, batchSize);
|
|
943
|
+
if (queued.length === 0)
|
|
944
|
+
return false;
|
|
945
|
+
const applicable = (await this.fetchMessageBatch(queued.map(({ uid }) => uid))).filter(hasEnvelope);
|
|
946
|
+
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
947
|
+
const departures = await this.probeDepartures(mailbox, accountId, applicable);
|
|
948
|
+
const outcomes = await pMap(applicable, (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles, departures), { concurrency: MESSAGE_SAVE_CONCURRENCY });
|
|
949
|
+
const failedUids = new Set(outcomes.flatMap((outcome) => outcome.kind === "failed" ? [outcome.uid] : []));
|
|
950
|
+
await this.envelopeService.clearStructureRefetches(queued
|
|
951
|
+
.filter(({ uid }) => !failedUids.has(uid))
|
|
952
|
+
.map(({ messageId }) => messageId));
|
|
953
|
+
this.log.info({
|
|
954
|
+
mailboxId: mailbox.mailboxId,
|
|
955
|
+
requested: queued.length,
|
|
956
|
+
saved: outcomes.length - failedUids.size,
|
|
957
|
+
failed: failedUids.size,
|
|
958
|
+
}, "Re-read the structure of messages the forwarded-message repair queued");
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Fetch a batch of messages using the managed connection.
|
|
963
|
+
* Assumes mailbox is already open from fetchUidsToSync.
|
|
964
|
+
*/
|
|
965
|
+
async fetchMessageBatch(uids) {
|
|
966
|
+
const connection = this.connectionFactory.getConnection();
|
|
967
|
+
return await connection.fetchMessages(uids);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Save a single message without ever rejecting. Any error is caught and
|
|
971
|
+
* reported as a `failed` outcome so it cannot abort the surrounding `pMap`
|
|
972
|
+
* batch — the failed UID is held back from the watermark and retried next
|
|
973
|
+
* cycle. This is the guardrail that stops a single unsaveable message from
|
|
974
|
+
* permanently freezing the mailbox (#817).
|
|
975
|
+
*/
|
|
976
|
+
/**
|
|
977
|
+
* Which folders hold Junk and Trash for this account, resolved once per
|
|
978
|
+
* batch rather than per message: the lookup reads an appointment row and
|
|
979
|
+
* the account’s mailbox list, and every message in a batch shares both.
|
|
980
|
+
*/
|
|
981
|
+
async folderRolesFor(accountId, accountConfigId) {
|
|
982
|
+
const [junk, trash, configJunkRoles] = await Promise.all([
|
|
983
|
+
this.mailboxSpecialUseService.findJunkMailbox(accountId),
|
|
984
|
+
this.mailboxSpecialUseService.findTrashMailbox(accountId),
|
|
985
|
+
this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId),
|
|
986
|
+
]);
|
|
987
|
+
return {
|
|
988
|
+
junkMailboxId: junk?.mailboxId ?? null,
|
|
989
|
+
trashMailboxId: trash?.mailboxId ?? null,
|
|
990
|
+
configJunkRoles,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Ask the folders this batch's rows point at whether they still hold them
|
|
995
|
+
* (#1146).
|
|
996
|
+
*
|
|
997
|
+
* Between the batch FETCH and the save pass, in all three rounds, and in that
|
|
998
|
+
* position on purpose. It SELECTs another folder on the one connection this
|
|
999
|
+
* sync has, so it may not run while this mailbox's own fetches are still
|
|
1000
|
+
* outstanding; and the save pass has to have the answer already, because the
|
|
1001
|
+
* transaction that re-points a row is where the answer is spent. Nothing
|
|
1002
|
+
* after the save pass touches IMAP, so leaving another folder selected costs
|
|
1003
|
+
* the round nothing.
|
|
1004
|
+
*/
|
|
1005
|
+
async probeDepartures(mailbox, accountId, applicable) {
|
|
1006
|
+
return confirmDepartures({
|
|
1007
|
+
connection: this.connectionFactory.getConnection(),
|
|
1008
|
+
mailboxService: this.mailboxService,
|
|
1009
|
+
messageService: this.messageService,
|
|
1010
|
+
log: this.log,
|
|
1011
|
+
}, accountId, mailbox, applicable.map((msg) => messageIdForSighting(accountId, mailbox.mailboxId, msg)));
|
|
1012
|
+
}
|
|
1013
|
+
async trySaveMessage(mailbox, accountId, accountConfigId, msg, roles, departures) {
|
|
1014
|
+
const mailboxId = mailbox.mailboxId;
|
|
1015
|
+
return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles, departures)
|
|
1016
|
+
.then((result) => ({ kind: "saved", uid: msg.uid, result }))
|
|
1017
|
+
.catch((error) => {
|
|
1018
|
+
this.log.warn({
|
|
1019
|
+
mailboxId,
|
|
1020
|
+
uid: msg.uid,
|
|
1021
|
+
messageId: msg.envelope?.messageId,
|
|
1022
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1023
|
+
}, "Failed to save message; will retry on next sync");
|
|
1024
|
+
return { kind: "failed", uid: msg.uid };
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
async saveMessage(mailbox, accountId, accountConfigId, msg, roles, departures) {
|
|
1028
|
+
if (!hasEnvelope(msg))
|
|
1029
|
+
return null;
|
|
1030
|
+
const mailboxId = mailbox.mailboxId;
|
|
1031
|
+
const sighting = addressSightingIn(mailboxId, roles);
|
|
1032
|
+
// Store envelope to preserve narrowing in closures
|
|
1033
|
+
const envelope = msg.envelope;
|
|
1034
|
+
const messageId = messageIdForSighting(accountId, mailboxId, msg);
|
|
1035
|
+
const envelopeId = deriveEnvelopeId(messageId);
|
|
1036
|
+
const rootBodyPartId = deriveBodyPartId(messageId, ROOT_PART_PATH);
|
|
1037
|
+
const internalDateMs = msg.internalDate.getTime();
|
|
1038
|
+
const { value: sentDate, usedFallback: dateFellBack } = parseHeaderDate(envelope.date, internalDateMs);
|
|
1039
|
+
if (dateFellBack) {
|
|
1040
|
+
this.log.warn({
|
|
1041
|
+
mailboxId,
|
|
1042
|
+
messageId,
|
|
1043
|
+
uid: msg.uid,
|
|
1044
|
+
dateRaw: envelope.date,
|
|
1045
|
+
}, "Unparseable Date header; fell back to IMAP internalDate");
|
|
1046
|
+
}
|
|
1047
|
+
// Prepare all address save operations
|
|
1048
|
+
const addressOps = [
|
|
1049
|
+
{ addresses: envelope.from, role: AddressRole.From },
|
|
1050
|
+
{ addresses: envelope.sender, role: AddressRole.Sender },
|
|
1051
|
+
{ addresses: envelope.replyTo, role: AddressRole.ReplyTo },
|
|
1052
|
+
{ addresses: envelope.to, role: AddressRole.To },
|
|
1053
|
+
{ addresses: envelope.cc, role: AddressRole.Cc },
|
|
1054
|
+
{ addresses: envelope.bcc, role: AddressRole.Bcc },
|
|
1055
|
+
];
|
|
1056
|
+
const bodyParts = buildBodyPartUpserts(msg.bodyStructure);
|
|
1057
|
+
const hasAttachment = bodyParts.some((p) => !p.isMultipart && p.disposition === "attachment");
|
|
1058
|
+
// Ownership of this messageId by the current mailbox: the row was created
|
|
1059
|
+
// by this call, or an existing row already belongs to this mailbox. A
|
|
1060
|
+
// conflict whose stored row points at a different mailbox is foreign-owned
|
|
1061
|
+
// and must not feed this mailbox's watermark / body-sync (#634).
|
|
1062
|
+
let owned = false;
|
|
1063
|
+
let unresolvedSighting = false;
|
|
1064
|
+
// Every write below is an idempotent upsert keyed on a derived id, so a
|
|
1065
|
+
// save that fails part-way is repaired by the next round repeating it: the
|
|
1066
|
+
// UID's watermark only moves once the save returns. The order is the
|
|
1067
|
+
// guarantee a reader gets in between: the Envelope lands before the
|
|
1068
|
+
// Message (#1072), and the ThreadMessage is written last so the list path
|
|
1069
|
+
// never anchors on a ThreadMessage whose Message does not yet exist
|
|
1070
|
+
// (#1209).
|
|
1071
|
+
await this.envelopeService.upsertEnvelope({
|
|
1072
|
+
envelopeId,
|
|
1073
|
+
messageId,
|
|
1074
|
+
dateValue: sentDate,
|
|
1075
|
+
dateRaw: envelope.date,
|
|
1076
|
+
subject: envelope.subject,
|
|
1077
|
+
messageIdValue: envelope.messageId,
|
|
1078
|
+
});
|
|
1079
|
+
const messageReferences = messageReferencesOf(envelope, msg.references);
|
|
1080
|
+
if (messageReferences.length > 0) {
|
|
1081
|
+
await this.envelopeService.replaceMessageReferences(messageId, messageReferences);
|
|
1082
|
+
}
|
|
1083
|
+
for (const { addresses, role } of addressOps) {
|
|
1084
|
+
await this.saveAddresses(this.addressService, messageId, accountConfigId, addresses, role, sighting);
|
|
1085
|
+
}
|
|
1086
|
+
// IMAP returns BODYSTRUCTURE in the same FETCH that returns the
|
|
1087
|
+
// envelope, so persisting BodyParts here is "free" — no extra round-trip.
|
|
1088
|
+
if (bodyParts.length > 0) {
|
|
1089
|
+
await this.envelopeService.upsertBodyParts(messageId, bodyParts);
|
|
1090
|
+
}
|
|
1091
|
+
const { item, created } = await this.messageService.upsertWithStatus({
|
|
1092
|
+
messageId,
|
|
1093
|
+
mailboxId,
|
|
1094
|
+
uid: msg.uid,
|
|
1095
|
+
sequenceNumber: msg.seq,
|
|
1096
|
+
rfc822Size: msg.size ?? 0,
|
|
1097
|
+
internalDate: msg.internalDate.getTime(),
|
|
1098
|
+
envelopeId,
|
|
1099
|
+
rootBodyPartId,
|
|
1100
|
+
});
|
|
1101
|
+
// The server has just named this folder and this UID for a message the
|
|
1102
|
+
// database holds under another folder. `upsertWithStatus` left the row
|
|
1103
|
+
// alone, so the pointer is repaired here — mailbox and UID together,
|
|
1104
|
+
// because a UID only means anything inside the folder that issued it.
|
|
1105
|
+
//
|
|
1106
|
+
// Only when the message has really left the folder the row points at
|
|
1107
|
+
// (#1146). A sighting alone does not say that: a Gmail label, a Sieve
|
|
1108
|
+
// `fileinto` beside a `keep` and an echoed Sent copy all put the same
|
|
1109
|
+
// message in a second real folder while the first still holds it, and
|
|
1110
|
+
// following those took the mail out of the Inbox. `confirmDepartures`
|
|
1111
|
+
// asked the source before this batch was saved, and its verdict names
|
|
1112
|
+
// the placement it was reached for — so a row that has moved since
|
|
1113
|
+
// matches nothing, and `unresolvedSighting` holds this UID back for
|
|
1114
|
+
// the next round rather than repairing the row against a stale answer.
|
|
1115
|
+
const contested = !created && sightingContestsPlacement(mailbox, item);
|
|
1116
|
+
const verdict = placementKey(messageId, item.mailboxId, item.uid);
|
|
1117
|
+
unresolvedSighting = contested && !departures.settled.has(verdict);
|
|
1118
|
+
const repointable = contested &&
|
|
1119
|
+
departures.departed.has(verdict) &&
|
|
1120
|
+
!(await this.holdsCopyOf(this.messageService, messageId, mailboxId));
|
|
1121
|
+
// A user move landing between the read above and this write wins: the
|
|
1122
|
+
// re-point names the placement it read, writes nothing when the row has
|
|
1123
|
+
// moved since, and the UID waits for the next round.
|
|
1124
|
+
const repointed = repointable &&
|
|
1125
|
+
(await this.messageService.repointIfPlacement(messageId, { status: item.status, mailboxId: item.mailboxId, uid: item.uid }, { mailboxId, uid: msg.uid, isDeleted: sighting === "discarded" }));
|
|
1126
|
+
if (repointable && !repointed)
|
|
1127
|
+
unresolvedSighting = true;
|
|
1128
|
+
owned = created || item.mailboxId === mailboxId || repointed;
|
|
1129
|
+
// Withholding reads the pointer, so it is re-derived whenever the
|
|
1130
|
+
// pointer could have changed the answer: a save into Junk, and a
|
|
1131
|
+
// re-point in either direction. The reconcile both applies and lifts,
|
|
1132
|
+
// so a sender whose only mail was moved out of Junk is restored by the
|
|
1133
|
+
// same call that would have withheld them (#859).
|
|
1134
|
+
if (sighting === "junk" || repointed) {
|
|
1135
|
+
await this.addressService.reconcileJunkOnlyForMessage(messageId, roles.configJunkRoles);
|
|
1136
|
+
}
|
|
1137
|
+
await this.createThreadForMessage(this.threadMessageService, messageId, mailboxId, accountId, accountConfigId, msg.uid, msg.internalDate.getTime(), sentDate, envelope, msg.flags,
|
|
1138
|
+
// The category of the Message this same save just wrote. A new
|
|
1139
|
+
// message is `uncategorized` until body-sync classifies it; a row
|
|
1140
|
+
// created for a Message that is ALREADY classified takes the decided
|
|
1141
|
+
// value rather than sitting stale forever behind a body-sync pass that
|
|
1142
|
+
// will skip it. That happens on thread-root drift — the same message
|
|
1143
|
+
// re-saved under different `References` mints a second threadId and so
|
|
1144
|
+
// a second row — not on a second mailbox, which resolves to the row
|
|
1145
|
+
// that already exists (issue #320).
|
|
1146
|
+
item.category, msg.references, hasAttachment);
|
|
1147
|
+
return { messageId, uid: msg.uid, owned, unresolvedSighting };
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Whether this folder already holds a copy the user made of this message
|
|
1151
|
+
* (#75). A copy is a second row under its own derived id, so the sighting is
|
|
1152
|
+
* that copy standing where it was put — the original has not moved, and
|
|
1153
|
+
* following it would take the mail out of the folder the user copied it from.
|
|
1154
|
+
*/
|
|
1155
|
+
async holdsCopyOf(messageService, messageId, mailboxId) {
|
|
1156
|
+
const rows = await messageService.get([
|
|
1157
|
+
deriveCopyMessageId(messageId, mailboxId),
|
|
1158
|
+
]);
|
|
1159
|
+
return rows.length > 0;
|
|
1160
|
+
}
|
|
1161
|
+
async saveAddresses(addressService, messageId, accountConfigId, addresses, role, sighting) {
|
|
1162
|
+
if (!addresses)
|
|
1163
|
+
return;
|
|
1164
|
+
// Pre-compute address data with order indices, filtering valid addresses
|
|
1165
|
+
const addressData = [];
|
|
1166
|
+
// An unusable address is dropped and the message is written anyway, and
|
|
1167
|
+
// that stays deliberate under the quarantine rules (issue #72). What is
|
|
1168
|
+
// lost is one envelope address, not the message: it is stored, listed and
|
|
1169
|
+
// readable, and its body is untouched. Setting the whole message aside
|
|
1170
|
+
// over a malformed From would take readable mail out of the mailbox to
|
|
1171
|
+
// protect a display name.
|
|
1172
|
+
for (let i = 0; i < addresses.length; i++) {
|
|
1173
|
+
const addr = addresses[i];
|
|
1174
|
+
if (!isParseableEmailAddress(addr))
|
|
1175
|
+
continue;
|
|
1176
|
+
addressData.push({
|
|
1177
|
+
localPart: addr.mailbox,
|
|
1178
|
+
domain: addr.host,
|
|
1179
|
+
displayName: harvestedDisplayName(addr),
|
|
1180
|
+
order: i,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
for (const { localPart, domain, displayName, order } of addressData) {
|
|
1184
|
+
const normalizedEmail = `${localPart}@${domain}`.toLowerCase();
|
|
1185
|
+
const normalizedCompound = `${displayName.toLowerCase()} ${normalizedEmail}`.trim();
|
|
1186
|
+
const addressId = deriveAddressId(accountConfigId, normalizedEmail);
|
|
1187
|
+
const addressInput = {
|
|
1188
|
+
addressId,
|
|
1189
|
+
accountConfigId,
|
|
1190
|
+
localPart,
|
|
1191
|
+
domain,
|
|
1192
|
+
normalizedEmail,
|
|
1193
|
+
normalizedCompound,
|
|
1194
|
+
displayName,
|
|
1195
|
+
};
|
|
1196
|
+
if (sighting === "junk") {
|
|
1197
|
+
await addressService.upsertJunkAddress(addressInput);
|
|
1198
|
+
}
|
|
1199
|
+
else if (sighting === "discarded") {
|
|
1200
|
+
await addressService.upsertAddress(addressInput);
|
|
1201
|
+
}
|
|
1202
|
+
else {
|
|
1203
|
+
await addressService.upsertCorrespondentAddress(addressInput);
|
|
1204
|
+
}
|
|
1205
|
+
await addressService.upsertEnvelopeAddress({
|
|
1206
|
+
messageId,
|
|
1207
|
+
addressId,
|
|
1208
|
+
displayName,
|
|
1209
|
+
normalizedEmail,
|
|
1210
|
+
addressRole: role,
|
|
1211
|
+
addressOrder: order,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* The thread a synced message files under. A message keeps the thread it is
|
|
1217
|
+
* already stored in; a reply joins the thread its parent is stored in;
|
|
1218
|
+
* otherwise the thread is derived from the root `threadRootOf` names.
|
|
1219
|
+
*/
|
|
1220
|
+
async threadFor(threadMessageService, messageId, accountId, accountConfigId, envelope, references) {
|
|
1221
|
+
const own = await threadMessageService.findByMessageId(accountConfigId, messageId);
|
|
1222
|
+
if (own)
|
|
1223
|
+
return own.threadId;
|
|
1224
|
+
const parent = parentOf(envelope, references);
|
|
1225
|
+
const parentRow = parent
|
|
1226
|
+
? await threadMessageService.findByMessageId(accountConfigId, deriveMessageId(accountId, parent))
|
|
1227
|
+
: null;
|
|
1228
|
+
return (parentRow?.threadId ??
|
|
1229
|
+
deriveThreadId(accountConfigId, threadRootOf(envelope, references, messageId)));
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Joins the rows written on either side of a parent and its reply, after
|
|
1233
|
+
* this message's own row exists. Replies that arrived before this message,
|
|
1234
|
+
* and so were keyed on its own id, follow it into its thread; and a reply
|
|
1235
|
+
* whose parent was saved while it was being saved follows the parent, but
|
|
1236
|
+
* only from the thread it derived for itself — a thread it joined or was
|
|
1237
|
+
* already stored in is left as it is.
|
|
1238
|
+
*
|
|
1239
|
+
* Both checks run after the create, so of a parent and a reply saved
|
|
1240
|
+
* concurrently at least one sees the other's row: neither is left in a
|
|
1241
|
+
* thread of its own.
|
|
1242
|
+
*/
|
|
1243
|
+
async joinThreads(threadMessageService, messageId, accountId, accountConfigId, envelope, references, threadId) {
|
|
1244
|
+
if (isValidMessageId(envelope.messageId)) {
|
|
1245
|
+
await threadMessageService.rekeyThread(accountConfigId, deriveThreadId(accountConfigId, wireMessageId(envelope.messageId)), threadId);
|
|
1246
|
+
}
|
|
1247
|
+
const parent = parentOf(envelope, references);
|
|
1248
|
+
if (!parent)
|
|
1249
|
+
return;
|
|
1250
|
+
const derived = deriveThreadId(accountConfigId, threadRootOf(envelope, references, messageId));
|
|
1251
|
+
if (threadId !== derived)
|
|
1252
|
+
return;
|
|
1253
|
+
const parentRow = await threadMessageService.findByMessageId(accountConfigId, deriveMessageId(accountId, parent));
|
|
1254
|
+
if (!parentRow || parentRow.threadId === threadId)
|
|
1255
|
+
return;
|
|
1256
|
+
await threadMessageService.rekeyThread(accountConfigId, threadId, parentRow.threadId);
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Create the ThreadMessage for a synced message, then join it to its
|
|
1260
|
+
* parent and replies.
|
|
1261
|
+
*
|
|
1262
|
+
* `category` is the value the caller already holds from the Message it just
|
|
1263
|
+
* wrote, so the row starts out agreeing with it rather than defaulting to
|
|
1264
|
+
* `uncategorized` behind an already-classified message.
|
|
1265
|
+
*/
|
|
1266
|
+
async createThreadForMessage(threadMessageService, messageId, mailboxId, accountId, accountConfigId, uid, internalDate, sentDate, envelope, flags, category, references, hasAttachment = false) {
|
|
1267
|
+
const threadId = await this.threadFor(threadMessageService, messageId, accountId, accountConfigId, envelope, references);
|
|
1268
|
+
const isRead = flags.includes(MessageSystemFlag.Seen);
|
|
1269
|
+
// The server's \Flagged keyword is the star. Mail flagged in another
|
|
1270
|
+
// client must arrive starred, so carry it through on create rather than
|
|
1271
|
+
// defaulting every row to unstarred. Both comparisons go through the
|
|
1272
|
+
// generated members, which carry the wire spelling (reader#65) and are
|
|
1273
|
+
// the same values the flag-push markers are keyed by — one source of
|
|
1274
|
+
// truth for the wire flag and the record of it.
|
|
1275
|
+
const hasStars = flags.includes(MessageSystemFlag.Flagged);
|
|
1276
|
+
// Extract sender info. When the server could not parse the From address,
|
|
1277
|
+
// omit fromEmail rather than persist a fabricated string — a display name
|
|
1278
|
+
// may still be present and useful, so keep it. This is the sender label the
|
|
1279
|
+
// message list shows and the search index tokenizes, so it takes the same
|
|
1280
|
+
// guard as the address book (issue #826).
|
|
1281
|
+
const fromAddr = envelope.from?.[0];
|
|
1282
|
+
const fromEmail = isParseableEmailAddress(fromAddr)
|
|
1283
|
+
? `${fromAddr?.mailbox}@${fromAddr?.host}`.toLowerCase()
|
|
1284
|
+
: undefined;
|
|
1285
|
+
const fromName = harvestedDisplayName(fromAddr) || undefined;
|
|
1286
|
+
// Calculate reference order (position in the thread chain)
|
|
1287
|
+
// references.length gives the position since References = [root, parent1, parent2, ...]
|
|
1288
|
+
const referenceOrder = references?.length ?? (envelope.inReplyTo ? 1 : 0);
|
|
1289
|
+
// Create ThreadMessage linking message to thread
|
|
1290
|
+
await threadMessageService
|
|
1291
|
+
.create({
|
|
1292
|
+
threadId,
|
|
1293
|
+
messageId,
|
|
1294
|
+
accountConfigId,
|
|
1295
|
+
mailboxId,
|
|
1296
|
+
uid,
|
|
1297
|
+
messageIdHeader: envelope.messageId,
|
|
1298
|
+
inReplyTo: envelope.inReplyTo,
|
|
1299
|
+
referenceOrder,
|
|
1300
|
+
fromEmail,
|
|
1301
|
+
fromName,
|
|
1302
|
+
subject: envelope.subject,
|
|
1303
|
+
internalDate,
|
|
1304
|
+
sentDate,
|
|
1305
|
+
isRead,
|
|
1306
|
+
isDeleted: false,
|
|
1307
|
+
hasAttachment,
|
|
1308
|
+
hasStars,
|
|
1309
|
+
star: hasStars ? StarColor.Yellow : StarColor.None,
|
|
1310
|
+
category,
|
|
1311
|
+
})
|
|
1312
|
+
.catch((error) => {
|
|
1313
|
+
// Ignore conflict errors (idempotent create)
|
|
1314
|
+
if (error?.name === "CreateFailedConflictError") {
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
throw error;
|
|
1318
|
+
});
|
|
1319
|
+
await this.joinThreads(threadMessageService, messageId, accountId, accountConfigId, envelope, references, threadId);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Translate the IMAP BODYSTRUCTURE for a single message into a list of
|
|
1324
|
+
* `BodyPartUpsertInput`s ready for `EnvelopeService.upsertBodyParts`.
|
|
1325
|
+
* Returns an empty list when the server didn't return BODYSTRUCTURE
|
|
1326
|
+
* (some unusual messages, or older test fixtures).
|
|
1327
|
+
*/
|
|
1328
|
+
const buildBodyPartUpserts = (bodyStructure) => {
|
|
1329
|
+
if (!bodyStructure)
|
|
1330
|
+
return [];
|
|
1331
|
+
return walkMimeStructure(bodyStructure).map((part) => ({
|
|
1332
|
+
partPath: part.partPath,
|
|
1333
|
+
parentPartPath: part.parentPartPath,
|
|
1334
|
+
mediaType: part.mediaType,
|
|
1335
|
+
mediaSubtype: part.mediaSubtype,
|
|
1336
|
+
transferEncoding: part.transferEncoding,
|
|
1337
|
+
sizeOctets: part.sizeOctets,
|
|
1338
|
+
isMultipart: part.isMultipart,
|
|
1339
|
+
parameters: part.parameters,
|
|
1340
|
+
...(part.contentId !== undefined ? { contentId: part.contentId } : {}),
|
|
1341
|
+
...(part.contentDescription !== undefined
|
|
1342
|
+
? { contentDescription: part.contentDescription }
|
|
1343
|
+
: {}),
|
|
1344
|
+
...(part.lineCount !== undefined ? { lineCount: part.lineCount } : {}),
|
|
1345
|
+
...(part.md5Hash !== undefined ? { md5Hash: part.md5Hash } : {}),
|
|
1346
|
+
...(part.disposition !== undefined
|
|
1347
|
+
? { disposition: part.disposition }
|
|
1348
|
+
: {}),
|
|
1349
|
+
...(part.dispositionFilename !== undefined
|
|
1350
|
+
? { dispositionFilename: part.dispositionFilename }
|
|
1351
|
+
: {}),
|
|
1352
|
+
...(part.language !== undefined ? { language: part.language } : {}),
|
|
1353
|
+
...(part.location !== undefined ? { location: part.location } : {}),
|
|
1354
|
+
...(part.multipartSubtype !== undefined
|
|
1355
|
+
? { multipartSubtype: part.multipartSubtype }
|
|
1356
|
+
: {}),
|
|
1357
|
+
}));
|
|
1358
|
+
};
|