@remit/mailbox-service 0.0.1
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/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BodyPartUpsertInput,
|
|
3
|
+
IAddressRepository,
|
|
4
|
+
IEnvelopeRepository,
|
|
5
|
+
IMailboxRepository,
|
|
6
|
+
IMessageRepository,
|
|
7
|
+
IThreadMessageRepository,
|
|
8
|
+
IUnitOfWork,
|
|
9
|
+
MailboxItem,
|
|
10
|
+
} from "@remit/data-ports";
|
|
11
|
+
import {
|
|
12
|
+
deriveAddressId,
|
|
13
|
+
deriveBodyPartId,
|
|
14
|
+
deriveEnvelopeAddressId,
|
|
15
|
+
deriveEnvelopeId,
|
|
16
|
+
deriveMessageIdFromSource,
|
|
17
|
+
deriveThreadId,
|
|
18
|
+
isValidMessageId,
|
|
19
|
+
} from "@remit/data-ports/id";
|
|
20
|
+
import { AddressRole, MailboxCursorState } from "@remit/domain-enums";
|
|
21
|
+
import pMap from "p-map";
|
|
22
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
23
|
+
import { guardMailboxCursor, isCursorRebuildNeeded } from "./mailbox-cursor.js";
|
|
24
|
+
import {
|
|
25
|
+
type CursorRebuildRow,
|
|
26
|
+
type CursorRebuildSnapshot,
|
|
27
|
+
matchCursorRebuild,
|
|
28
|
+
} from "./mailbox-cursor-rebuild.js";
|
|
29
|
+
import { ROOT_PART_PATH, walkMimeStructure } from "./mime-walker.js";
|
|
30
|
+
import { PassThroughUnitOfWork } from "./pass-through-unit-of-work.js";
|
|
31
|
+
import { reconcileStaleMessage } from "./stale-message-reconcile.js";
|
|
32
|
+
import type {
|
|
33
|
+
ImapAddress,
|
|
34
|
+
ImapBodyStructure,
|
|
35
|
+
ImapEnvelope,
|
|
36
|
+
ImapMessage,
|
|
37
|
+
} from "./types.js";
|
|
38
|
+
|
|
39
|
+
const MESSAGE_SAVE_CONCURRENCY = 10;
|
|
40
|
+
|
|
41
|
+
// Some IMAP servers (e.g. Hostnet) emit these literal placeholders in the
|
|
42
|
+
// ENVELOPE when they cannot parse a From header, instead of leaving the
|
|
43
|
+
// address parts empty.
|
|
44
|
+
const HOSTNET_MISSING_MAILBOX = "missing_mailbox";
|
|
45
|
+
const HOSTNET_MISSING_DOMAIN = "missing_domain";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A From address is only usable when it looks like a real mailbox: both parts
|
|
49
|
+
* present, neither is a known "could not parse" sentinel, and the host carries
|
|
50
|
+
* an actual domain (at least one dot). Detect this structurally so a fabricated
|
|
51
|
+
* string like `missing_mailbox@missing_domain` is never persisted as a sender.
|
|
52
|
+
*/
|
|
53
|
+
export const isParseableEmailAddress = (
|
|
54
|
+
address: ImapAddress | undefined,
|
|
55
|
+
): boolean => {
|
|
56
|
+
if (!address) return false;
|
|
57
|
+
const mailbox = address.mailbox?.trim();
|
|
58
|
+
const host = address.host?.trim();
|
|
59
|
+
if (!mailbox || !host) return false;
|
|
60
|
+
if (mailbox === HOSTNET_MISSING_MAILBOX || host === HOSTNET_MISSING_DOMAIN) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return host.includes(".");
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse an external `Date:` header into an epoch-millisecond integer.
|
|
68
|
+
*
|
|
69
|
+
* The IMAP envelope `date` is a raw RFC 2822 header copied verbatim from the
|
|
70
|
+
* message. It can be missing, malformed, or in a format `Date` cannot parse —
|
|
71
|
+
* `new Date(raw).getTime()` then yields `NaN`. `NaN` is not a valid integer
|
|
72
|
+
* and ElectroDB rejects it, which previously threw on the envelope upsert and
|
|
73
|
+
* (because the batch aborted on the first rejection) stalled the whole mailbox.
|
|
74
|
+
*
|
|
75
|
+
* When the header is unparseable we fall back to `fallbackMs` — the IMAP
|
|
76
|
+
* server's own INTERNALDATE receive time, always a valid integer. The raw
|
|
77
|
+
* header is preserved separately in `dateRaw`, so nothing is lost.
|
|
78
|
+
*/
|
|
79
|
+
export const parseHeaderDate = (
|
|
80
|
+
raw: string | undefined,
|
|
81
|
+
fallbackMs: number,
|
|
82
|
+
): { value: number; usedFallback: boolean } => {
|
|
83
|
+
if (raw !== undefined && raw !== "") {
|
|
84
|
+
const parsed = new Date(raw).getTime();
|
|
85
|
+
if (Number.isFinite(parsed)) {
|
|
86
|
+
return { value: parsed, usedFallback: false };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { value: fallbackMs, usedFallback: true };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @deprecated Use ManagedConnectionFactory instead
|
|
94
|
+
*/
|
|
95
|
+
export type ImapConnectionFactory = () => {
|
|
96
|
+
connect(): Promise<void>;
|
|
97
|
+
disconnect(): Promise<void>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export interface SyncLogger {
|
|
101
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
102
|
+
warn(obj: Record<string, unknown>, msg: string): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const noopLogger: SyncLogger = {
|
|
106
|
+
info: () => {},
|
|
107
|
+
warn: () => {},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export interface SyncedMessage {
|
|
111
|
+
messageId: string;
|
|
112
|
+
uid: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Per-message save outcome. `owned` is true when the row was created by this
|
|
117
|
+
* sync or already belongs to the current mailbox; false for a residual
|
|
118
|
+
* cross-mailbox collision whose stored row points at a different mailbox.
|
|
119
|
+
*/
|
|
120
|
+
interface SaveMessageResult extends SyncedMessage {
|
|
121
|
+
owned: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Wrapper outcome for a single message in the batch. A `failed` outcome means
|
|
126
|
+
* the save threw (and was caught) — its UID must NOT advance the watermark, so
|
|
127
|
+
* the message is re-fetched and retried on the next cycle. `null` means the
|
|
128
|
+
* message carried no envelope and was intentionally skipped (nothing to retry).
|
|
129
|
+
*/
|
|
130
|
+
type BatchOutcome =
|
|
131
|
+
| { kind: "saved"; uid: number; result: SaveMessageResult | null }
|
|
132
|
+
| { kind: "failed"; uid: number };
|
|
133
|
+
|
|
134
|
+
export interface SyncMessagesResult {
|
|
135
|
+
syncedCount: number;
|
|
136
|
+
syncedMessageIds: string[];
|
|
137
|
+
syncedMessages: SyncedMessage[];
|
|
138
|
+
hasMore: boolean;
|
|
139
|
+
remainingCount: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class MessageSyncService {
|
|
143
|
+
private log: SyncLogger;
|
|
144
|
+
private unitOfWork: IUnitOfWork;
|
|
145
|
+
|
|
146
|
+
constructor(
|
|
147
|
+
private connectionFactory: ManagedConnectionFactory,
|
|
148
|
+
private mailboxService: IMailboxRepository,
|
|
149
|
+
messageService: IMessageRepository,
|
|
150
|
+
envelopeService: IEnvelopeRepository,
|
|
151
|
+
addressService: IAddressRepository,
|
|
152
|
+
private threadMessageService: IThreadMessageRepository,
|
|
153
|
+
logger?: SyncLogger,
|
|
154
|
+
unitOfWork?: IUnitOfWork,
|
|
155
|
+
) {
|
|
156
|
+
this.log = logger ?? noopLogger;
|
|
157
|
+
this.unitOfWork =
|
|
158
|
+
unitOfWork ??
|
|
159
|
+
new PassThroughUnitOfWork({
|
|
160
|
+
message: messageService,
|
|
161
|
+
envelope: envelopeService,
|
|
162
|
+
address: addressService,
|
|
163
|
+
threadMessage: threadMessageService,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sync ONE batch of messages for a mailbox using newest-first strategy.
|
|
169
|
+
*
|
|
170
|
+
* Uses dual-watermark tracking:
|
|
171
|
+
* - highWaterMarkUid: highest UID ever seen (detects new messages)
|
|
172
|
+
* - lastSyncUid: lowest UID processed (tracks backfill progress)
|
|
173
|
+
*
|
|
174
|
+
* Returns hasMore=true if there are more messages to sync. The caller
|
|
175
|
+
* should re-enqueue another sync event to continue processing.
|
|
176
|
+
*
|
|
177
|
+
* @param mailboxId - The database mailbox ID
|
|
178
|
+
* @param accountId - The account ID (scopes message/thread identity)
|
|
179
|
+
* @param accountConfigId - The account config ID (used for address linking)
|
|
180
|
+
* @param batchSize - Number of messages to process per batch
|
|
181
|
+
*/
|
|
182
|
+
async syncMessages(
|
|
183
|
+
mailboxId: string,
|
|
184
|
+
accountId: string,
|
|
185
|
+
accountConfigId: string,
|
|
186
|
+
batchSize = 50,
|
|
187
|
+
): Promise<SyncMessagesResult> {
|
|
188
|
+
const mailbox = await this.mailboxService.get(accountId, mailboxId);
|
|
189
|
+
const mailboxPath = mailbox.fullPath;
|
|
190
|
+
|
|
191
|
+
// A mailbox whose cursor is already invalid (or a rebuild that crashed
|
|
192
|
+
// mid-way, #1272) never falls through to the normal watermark-based sync
|
|
193
|
+
// below — stored UIDs on that axis are not trustworthy. The rebuild is a
|
|
194
|
+
// variant of sync, not a special wipe path, so it runs here under the
|
|
195
|
+
// same mailbox lock the caller already holds.
|
|
196
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
197
|
+
return this.rebuildCursor(mailbox, accountId, accountConfigId, mailboxId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const lastSyncUid = mailbox.lastSyncUid || 0;
|
|
201
|
+
const highWaterMarkUid = mailbox.highWaterMarkUid || 0;
|
|
202
|
+
|
|
203
|
+
const { box, unseenCount, deletedCount, uids } = await this.fetchUidsToSync(
|
|
204
|
+
mailboxPath,
|
|
205
|
+
lastSyncUid,
|
|
206
|
+
highWaterMarkUid,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Detection: the served UIDVALIDITY may have changed since it was last
|
|
210
|
+
// stored, even though this mailbox was `normal` a moment ago. Trip the
|
|
211
|
+
// cursor and pause — the watermarks just used to filter `uids` may
|
|
212
|
+
// already be meaningless on the new axis, so nothing below may be acted
|
|
213
|
+
// on this round (epic #1281 invariants 3 and 5).
|
|
214
|
+
const cursorCheck = await guardMailboxCursor(
|
|
215
|
+
{ mailboxService: this.mailboxService },
|
|
216
|
+
accountId,
|
|
217
|
+
mailbox,
|
|
218
|
+
box.uidvalidity,
|
|
219
|
+
);
|
|
220
|
+
if (!cursorCheck.ok) {
|
|
221
|
+
this.log.warn(
|
|
222
|
+
{ mailboxId, mailboxPath, cursorState: cursorCheck.state },
|
|
223
|
+
"UIDVALIDITY changed; mailbox cursor tripped, pausing outbound sync this round",
|
|
224
|
+
);
|
|
225
|
+
return {
|
|
226
|
+
syncedCount: 0,
|
|
227
|
+
syncedMessageIds: [],
|
|
228
|
+
syncedMessages: [],
|
|
229
|
+
hasMore: false,
|
|
230
|
+
remainingCount: 0,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (uids.length === 0) {
|
|
235
|
+
// Still update counts even if no new messages to sync
|
|
236
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
237
|
+
lastMessageSyncAt: Date.now(),
|
|
238
|
+
uidValidity: box.uidvalidity,
|
|
239
|
+
messageCount: box.messageCount,
|
|
240
|
+
unseenCount,
|
|
241
|
+
deletedCount,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
this.log.info(
|
|
245
|
+
{
|
|
246
|
+
mailboxId,
|
|
247
|
+
mailboxPath,
|
|
248
|
+
total: 0,
|
|
249
|
+
messageCount: box.messageCount,
|
|
250
|
+
unseenCount,
|
|
251
|
+
},
|
|
252
|
+
"No new messages to sync",
|
|
253
|
+
);
|
|
254
|
+
return {
|
|
255
|
+
syncedCount: 0,
|
|
256
|
+
syncedMessageIds: [],
|
|
257
|
+
syncedMessages: [],
|
|
258
|
+
hasMore: false,
|
|
259
|
+
remainingCount: 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const totalBatches = Math.ceil(uids.length / batchSize);
|
|
264
|
+
this.log.info(
|
|
265
|
+
{ mailboxId, mailboxPath, total: uids.length, batches: totalBatches },
|
|
266
|
+
"Starting message sync batch (newest first)",
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// Process only the first batch
|
|
270
|
+
const batchUids = uids.slice(0, batchSize);
|
|
271
|
+
const messages = await this.fetchMessageBatch(batchUids);
|
|
272
|
+
|
|
273
|
+
// Process messages in parallel with concurrency limit. `stopOnError` stays
|
|
274
|
+
// at its default — but each message is saved through `trySaveMessage`,
|
|
275
|
+
// which catches its own error and reports a `failed` outcome instead of
|
|
276
|
+
// rejecting. So one bad message can no longer abort the whole batch (the
|
|
277
|
+
// poison pill that previously froze the mailbox, #817).
|
|
278
|
+
const outcomes = await pMap(
|
|
279
|
+
messages,
|
|
280
|
+
(msg) => this.trySaveMessage(mailboxId, accountId, accountConfigId, msg),
|
|
281
|
+
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// Body-sync set: only rows created or owned by THIS mailbox. A residual
|
|
285
|
+
// cross-mailbox collision (same deterministic messageId already owned by a
|
|
286
|
+
// different mailbox) must not push a foreign-owned messageId into
|
|
287
|
+
// syncedMessageIds, or body-sync would FETCH against the wrong mailbox's UID.
|
|
288
|
+
const ownedResults = outcomes.flatMap((o) =>
|
|
289
|
+
o.kind === "saved" && o.result !== null && o.result.owned
|
|
290
|
+
? [o.result]
|
|
291
|
+
: [],
|
|
292
|
+
);
|
|
293
|
+
const syncedMessages: SyncedMessage[] = ownedResults.map(
|
|
294
|
+
({ messageId, uid }) => ({ messageId, uid }),
|
|
295
|
+
);
|
|
296
|
+
const syncedMessageIds = syncedMessages.map((m) => m.messageId);
|
|
297
|
+
|
|
298
|
+
// UIDs whose save threw. They must stay inside the next cycle's fetch
|
|
299
|
+
// window, so the watermark may not advance past them (no silent loss).
|
|
300
|
+
const failedUids = new Set(
|
|
301
|
+
outcomes.flatMap((o) => (o.kind === "failed" ? [o.uid] : [])),
|
|
302
|
+
);
|
|
303
|
+
if (failedUids.size > 0) {
|
|
304
|
+
this.log.warn(
|
|
305
|
+
{ mailboxId, mailboxPath, failedUids: [...failedUids] },
|
|
306
|
+
"Some messages failed to save; holding watermark below them for retry",
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Watermarks advance over every SUCCESSFULLY-consumed UID in the batch,
|
|
311
|
+
// independent of ownership. `fetchUidsToSync` reselects work purely by UID
|
|
312
|
+
// vs watermark (there is no per-UID processed set), so a foreign-owned UID
|
|
313
|
+
// that did not advance the watermark would be re-fetched every cycle
|
|
314
|
+
// forever. The same Message-ID legitimately appears in several of one
|
|
315
|
+
// account's mailboxes (Gmail All Mail + INBOX/labels), so cross-mailbox
|
|
316
|
+
// conflicts are routine; excluding them from body-sync is correct, stalling
|
|
317
|
+
// forward sync is not.
|
|
318
|
+
//
|
|
319
|
+
// Failures are different: the watermark range [batchMin, batchMax] jumps
|
|
320
|
+
// over any interior UID, so a failed UID inside the range would be lost.
|
|
321
|
+
// We therefore advance the forward watermark only past the top contiguous
|
|
322
|
+
// run of successes, and the backfill watermark only past the bottom
|
|
323
|
+
// contiguous run — clamping at the first failure from each end so every
|
|
324
|
+
// failed UID stays selectable next cycle.
|
|
325
|
+
const ascendingUids = [...batchUids].sort((a, b) => a - b);
|
|
326
|
+
|
|
327
|
+
// Top contiguous run of successes → the highest UID safe to mark "seen".
|
|
328
|
+
let forwardMax = highWaterMarkUid;
|
|
329
|
+
for (let i = ascendingUids.length - 1; i >= 0; i--) {
|
|
330
|
+
const uid = ascendingUids[i];
|
|
331
|
+
if (failedUids.has(uid)) break;
|
|
332
|
+
forwardMax = Math.max(forwardMax, uid);
|
|
333
|
+
}
|
|
334
|
+
const newHighWaterMark = forwardMax;
|
|
335
|
+
|
|
336
|
+
// Bottom contiguous run of successes → the lowest UID safe to backfill
|
|
337
|
+
// past. The first (lowest) UID that succeeded defines it; if the very
|
|
338
|
+
// lowest UID failed there is nothing safe to backfill past.
|
|
339
|
+
const backfillMin: number | undefined = failedUids.has(ascendingUids[0])
|
|
340
|
+
? undefined
|
|
341
|
+
: ascendingUids[0];
|
|
342
|
+
|
|
343
|
+
// Update lastSyncUid only for backfill UIDs (below current lastSyncUid or
|
|
344
|
+
// fresh sync). When the lowest UID failed there is nothing safe to backfill
|
|
345
|
+
// past, so leave lastSyncUid untouched.
|
|
346
|
+
const newLastSyncUid =
|
|
347
|
+
backfillMin !== undefined &&
|
|
348
|
+
(lastSyncUid === 0 || backfillMin < lastSyncUid)
|
|
349
|
+
? backfillMin
|
|
350
|
+
: lastSyncUid;
|
|
351
|
+
|
|
352
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
353
|
+
lastSyncUid: newLastSyncUid,
|
|
354
|
+
highWaterMarkUid: newHighWaterMark,
|
|
355
|
+
lastMessageSyncAt: Date.now(),
|
|
356
|
+
uidValidity: box.uidvalidity,
|
|
357
|
+
messageCount: box.messageCount,
|
|
358
|
+
unseenCount,
|
|
359
|
+
deletedCount,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const remainingCount = uids.length - batchUids.length;
|
|
363
|
+
const hasMore = remainingCount > 0;
|
|
364
|
+
|
|
365
|
+
this.log.info(
|
|
366
|
+
{
|
|
367
|
+
batch: 1,
|
|
368
|
+
totalBatches,
|
|
369
|
+
batchSize: messages.length,
|
|
370
|
+
synced: syncedMessageIds.length,
|
|
371
|
+
total: uids.length,
|
|
372
|
+
remaining: remainingCount,
|
|
373
|
+
hasMore,
|
|
374
|
+
highWaterMarkUid: newHighWaterMark,
|
|
375
|
+
lastSyncUid: newLastSyncUid,
|
|
376
|
+
},
|
|
377
|
+
"Batch complete",
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
syncedCount: syncedMessageIds.length,
|
|
382
|
+
syncedMessageIds,
|
|
383
|
+
syncedMessages,
|
|
384
|
+
hasMore,
|
|
385
|
+
remainingCount,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Re-key a mailbox's stored UIDs against a new UIDVALIDITY axis (#1272).
|
|
391
|
+
*
|
|
392
|
+
* One envelope-level pass (UID + Message-ID + INTERNALDATE, no body
|
|
393
|
+
* fetches — {@link matchCursorRebuild}) matched against the rows already
|
|
394
|
+
* stored for this mailbox:
|
|
395
|
+
* - Match → rewrite the row's UID mapping in place; bodies and threads are
|
|
396
|
+
* untouched.
|
|
397
|
+
* - Server message with no row → normal new-message sync (the same
|
|
398
|
+
* `trySaveMessage` pipeline the regular batch sync uses).
|
|
399
|
+
* - Row with no counterpart → expunged; reconcile via {@link
|
|
400
|
+
* reconcileStaleMessage} (#1283 — the exact same "gone upstream" outcome
|
|
401
|
+
* as a body-sync retry exhaustion finding a stale row).
|
|
402
|
+
*
|
|
403
|
+
* Idempotent by construction: re-entering mid-rebuild (crash recovery —
|
|
404
|
+
* the mailbox was left `rebuilding`) simply redoes the same match/rewrite
|
|
405
|
+
* pass, which converges on the same result. `cursorState` is stamped
|
|
406
|
+
* `rebuilding` before any write and only cleared to `normal` after the
|
|
407
|
+
* watermarks are rebuilt, so a crash anywhere in between leaves the
|
|
408
|
+
* mailbox paused rather than falling back to the stale axis.
|
|
409
|
+
*/
|
|
410
|
+
private async rebuildCursor(
|
|
411
|
+
mailbox: MailboxItem,
|
|
412
|
+
accountId: string,
|
|
413
|
+
accountConfigId: string,
|
|
414
|
+
mailboxId: string,
|
|
415
|
+
): Promise<SyncMessagesResult> {
|
|
416
|
+
const mailboxPath = mailbox.fullPath;
|
|
417
|
+
|
|
418
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
419
|
+
cursorState: MailboxCursorState.rebuilding,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const connection = this.connectionFactory.getConnection();
|
|
423
|
+
const box = await connection.openBox(mailboxPath);
|
|
424
|
+
const allUids = await connection.search(["ALL"]);
|
|
425
|
+
const snapshots = await connection.fetchEnvelopeSnapshots(allUids);
|
|
426
|
+
const serverSnapshots: CursorRebuildSnapshot[] = snapshots.map((s) => ({
|
|
427
|
+
uid: s.uid,
|
|
428
|
+
messageId: s.messageId,
|
|
429
|
+
internalDate: s.internalDate.getTime(),
|
|
430
|
+
}));
|
|
431
|
+
|
|
432
|
+
const existingRows = await this.listExistingCursorRows(
|
|
433
|
+
accountConfigId,
|
|
434
|
+
mailboxId,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const { matched, newUids, staleMessageIds } = matchCursorRebuild(
|
|
438
|
+
serverSnapshots,
|
|
439
|
+
existingRows,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
// Bounded concurrency (mirrors MESSAGE_SAVE_CONCURRENCY below) — a
|
|
443
|
+
// sequential loop over a large mailbox's full match set risked the
|
|
444
|
+
// Lambda timeout on its own, independent of the fetch-size question
|
|
445
|
+
// (#1272 review, non-blocking finding).
|
|
446
|
+
await pMap(
|
|
447
|
+
matched,
|
|
448
|
+
async ({ messageId, newUid, threadMessage }) => {
|
|
449
|
+
await this.unitOfWork.transaction((repos) =>
|
|
450
|
+
repos.message.updateUid(messageId, newUid, mailboxId),
|
|
451
|
+
);
|
|
452
|
+
// Rewrite the denormalized ThreadMessage.uid alongside Message.uid —
|
|
453
|
+
// a normal move keeps both in sync (see buildThreadMessageMoveUpdate
|
|
454
|
+
// in message-move.ts), and the rebuild must too, or a resumed
|
|
455
|
+
// rebuild re-emits a no-op rewrite forever (listExistingCursorRows
|
|
456
|
+
// reads uid from ThreadMessage) and any reader of the list
|
|
457
|
+
// projection sees a stale UID.
|
|
458
|
+
if (threadMessage) {
|
|
459
|
+
await this.unitOfWork.transaction((repos) =>
|
|
460
|
+
repos.threadMessage.update(
|
|
461
|
+
threadMessage.accountConfigId,
|
|
462
|
+
threadMessage.threadMessageId,
|
|
463
|
+
{ uid: newUid },
|
|
464
|
+
{
|
|
465
|
+
composites: {
|
|
466
|
+
sentDate: threadMessage.sentDate,
|
|
467
|
+
mailboxId: threadMessage.mailboxId,
|
|
468
|
+
isRead: threadMessage.isRead,
|
|
469
|
+
isDeleted: threadMessage.isDeleted,
|
|
470
|
+
hasStars: threadMessage.hasStars,
|
|
471
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
await pMap(
|
|
482
|
+
staleMessageIds,
|
|
483
|
+
(messageId) =>
|
|
484
|
+
this.unitOfWork.transaction((repos) =>
|
|
485
|
+
reconcileStaleMessage(
|
|
486
|
+
{
|
|
487
|
+
messageService: repos.message,
|
|
488
|
+
threadMessageService: repos.threadMessage,
|
|
489
|
+
},
|
|
490
|
+
accountConfigId,
|
|
491
|
+
messageId,
|
|
492
|
+
),
|
|
493
|
+
),
|
|
494
|
+
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const newMessages =
|
|
498
|
+
newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
|
|
499
|
+
const outcomes = await pMap(
|
|
500
|
+
newMessages,
|
|
501
|
+
(msg) => this.trySaveMessage(mailboxId, accountId, accountConfigId, msg),
|
|
502
|
+
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
503
|
+
);
|
|
504
|
+
const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
|
|
505
|
+
o.kind === "saved" && o.result !== null && o.result.owned
|
|
506
|
+
? [{ messageId: o.result.messageId, uid: o.result.uid }]
|
|
507
|
+
: [],
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const serverUids = serverSnapshots.map((s) => s.uid);
|
|
511
|
+
const status = await connection.getMailboxStatus(mailboxPath);
|
|
512
|
+
|
|
513
|
+
await this.mailboxService.update(accountId, mailboxId, {
|
|
514
|
+
cursorState: MailboxCursorState.normal,
|
|
515
|
+
uidValidity: box.uidvalidity,
|
|
516
|
+
highWaterMarkUid: serverUids.length > 0 ? Math.max(...serverUids) : 0,
|
|
517
|
+
lastSyncUid: serverUids.length > 0 ? Math.min(...serverUids) : 0,
|
|
518
|
+
highestModseq: status.highestModseq,
|
|
519
|
+
lastMessageSyncAt: Date.now(),
|
|
520
|
+
messageCount: status.messages,
|
|
521
|
+
unseenCount: status.unseen,
|
|
522
|
+
deletedCount: status.deletedCount,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
this.log.info(
|
|
526
|
+
{
|
|
527
|
+
mailboxId,
|
|
528
|
+
mailboxPath,
|
|
529
|
+
matched: matched.length,
|
|
530
|
+
newMessages: syncedMessages.length,
|
|
531
|
+
stale: staleMessageIds.length,
|
|
532
|
+
},
|
|
533
|
+
"Mailbox cursor rebuild complete; returned to normal",
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
syncedCount: syncedMessages.length,
|
|
538
|
+
syncedMessageIds: syncedMessages.map((m) => m.messageId),
|
|
539
|
+
syncedMessages,
|
|
540
|
+
hasMore: false,
|
|
541
|
+
remainingCount: 0,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Page through every ThreadMessage row for this mailbox, projecting just
|
|
547
|
+
* the fields {@link matchCursorRebuild} needs. ThreadMessage (not
|
|
548
|
+
* Message) is the read source: it already denormalizes `messageIdHeader`
|
|
549
|
+
* and `internalDate` alongside `uid`, so this needs no per-row Envelope
|
|
550
|
+
* lookup.
|
|
551
|
+
*/
|
|
552
|
+
private async listExistingCursorRows(
|
|
553
|
+
accountConfigId: string,
|
|
554
|
+
mailboxId: string,
|
|
555
|
+
): Promise<CursorRebuildRow[]> {
|
|
556
|
+
const rows: CursorRebuildRow[] = [];
|
|
557
|
+
let continuationToken: string | undefined;
|
|
558
|
+
|
|
559
|
+
do {
|
|
560
|
+
const result = await this.threadMessageService.listByMailbox(
|
|
561
|
+
accountConfigId,
|
|
562
|
+
mailboxId,
|
|
563
|
+
{
|
|
564
|
+
continuationToken,
|
|
565
|
+
attributes: [
|
|
566
|
+
"messageId",
|
|
567
|
+
"messageIdHeader",
|
|
568
|
+
"internalDate",
|
|
569
|
+
"uid",
|
|
570
|
+
"accountConfigId",
|
|
571
|
+
"threadMessageId",
|
|
572
|
+
"sentDate",
|
|
573
|
+
"mailboxId",
|
|
574
|
+
"isRead",
|
|
575
|
+
"isDeleted",
|
|
576
|
+
"hasStars",
|
|
577
|
+
"hasAttachment",
|
|
578
|
+
],
|
|
579
|
+
},
|
|
580
|
+
);
|
|
581
|
+
for (const row of result.items) {
|
|
582
|
+
rows.push({
|
|
583
|
+
messageId: row.messageId,
|
|
584
|
+
messageIdHeader: row.messageIdHeader ?? "",
|
|
585
|
+
internalDate: row.internalDate,
|
|
586
|
+
uid: row.uid,
|
|
587
|
+
// Carried so a match can also rewrite ThreadMessage.uid (a normal
|
|
588
|
+
// move keeps both in sync — the rebuild must too, or #1271's
|
|
589
|
+
// push-time UID resolution can read a stale projection).
|
|
590
|
+
threadMessage: {
|
|
591
|
+
accountConfigId: row.accountConfigId,
|
|
592
|
+
threadMessageId: row.threadMessageId,
|
|
593
|
+
sentDate: row.sentDate,
|
|
594
|
+
mailboxId: row.mailboxId,
|
|
595
|
+
isRead: row.isRead,
|
|
596
|
+
isDeleted: row.isDeleted,
|
|
597
|
+
hasStars: row.hasStars,
|
|
598
|
+
hasAttachment: row.hasAttachment,
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
continuationToken = result.continuationToken;
|
|
603
|
+
} while (continuationToken);
|
|
604
|
+
|
|
605
|
+
return rows;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Fetch UIDs to sync using dual-watermark strategy.
|
|
610
|
+
*
|
|
611
|
+
* Returns UIDs sorted descending (newest first):
|
|
612
|
+
* 1. New messages: UIDs > highWaterMarkUid
|
|
613
|
+
* 2. Backfill: UIDs < lastSyncUid (if lastSyncUid > 1)
|
|
614
|
+
*/
|
|
615
|
+
private async fetchUidsToSync(
|
|
616
|
+
mailboxPath: string,
|
|
617
|
+
lastSyncUid: number,
|
|
618
|
+
highWaterMarkUid: number,
|
|
619
|
+
): Promise<{
|
|
620
|
+
box: { uidvalidity: number; uidnext: number; messageCount: number };
|
|
621
|
+
unseenCount: number;
|
|
622
|
+
deletedCount: number;
|
|
623
|
+
uids: number[];
|
|
624
|
+
}> {
|
|
625
|
+
const connection = this.connectionFactory.getConnection();
|
|
626
|
+
const box = await connection.openBox(mailboxPath);
|
|
627
|
+
|
|
628
|
+
// Get mailbox status including unseen count
|
|
629
|
+
const status = await connection.getMailboxStatus(mailboxPath);
|
|
630
|
+
|
|
631
|
+
const allUids = await connection.search(["ALL"]);
|
|
632
|
+
|
|
633
|
+
// New messages: UIDs greater than what we've seen
|
|
634
|
+
const newUids = allUids.filter((uid) => uid > highWaterMarkUid);
|
|
635
|
+
|
|
636
|
+
// Backfill: UIDs below our lowest synced point (if sync started)
|
|
637
|
+
const backfillUids =
|
|
638
|
+
lastSyncUid > 1 ? allUids.filter((uid) => uid < lastSyncUid) : [];
|
|
639
|
+
|
|
640
|
+
// Fresh sync: if no watermarks, sync everything
|
|
641
|
+
const isFreshSync = highWaterMarkUid === 0 && lastSyncUid === 0;
|
|
642
|
+
const uidsToSync = isFreshSync ? allUids : [...newUids, ...backfillUids];
|
|
643
|
+
|
|
644
|
+
// Sort descending (newest first)
|
|
645
|
+
uidsToSync.sort((a, b) => b - a);
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
box: {
|
|
649
|
+
uidvalidity: box.uidvalidity,
|
|
650
|
+
uidnext: box.uidnext,
|
|
651
|
+
messageCount: status.messages,
|
|
652
|
+
},
|
|
653
|
+
unseenCount: status.unseen,
|
|
654
|
+
deletedCount: status.deletedCount,
|
|
655
|
+
uids: uidsToSync,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Fetch a batch of messages using the managed connection.
|
|
661
|
+
* Assumes mailbox is already open from fetchUidsToSync.
|
|
662
|
+
*/
|
|
663
|
+
private async fetchMessageBatch(uids: number[]): Promise<ImapMessage[]> {
|
|
664
|
+
const connection = this.connectionFactory.getConnection();
|
|
665
|
+
return await connection.fetchMessages(uids);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Save a single message without ever rejecting. Any error is caught and
|
|
670
|
+
* reported as a `failed` outcome so it cannot abort the surrounding `pMap`
|
|
671
|
+
* batch — the failed UID is held back from the watermark and retried next
|
|
672
|
+
* cycle. This is the guardrail that stops a single unsaveable message from
|
|
673
|
+
* permanently freezing the mailbox (#817).
|
|
674
|
+
*/
|
|
675
|
+
private async trySaveMessage(
|
|
676
|
+
mailboxId: string,
|
|
677
|
+
accountId: string,
|
|
678
|
+
accountConfigId: string,
|
|
679
|
+
msg: ImapMessage,
|
|
680
|
+
): Promise<BatchOutcome> {
|
|
681
|
+
return this.saveMessage(mailboxId, accountId, accountConfigId, msg)
|
|
682
|
+
.then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
|
|
683
|
+
.catch((error): BatchOutcome => {
|
|
684
|
+
this.log.warn(
|
|
685
|
+
{
|
|
686
|
+
mailboxId,
|
|
687
|
+
uid: msg.uid,
|
|
688
|
+
messageId: msg.envelope?.messageId,
|
|
689
|
+
error: error instanceof Error ? error.message : String(error),
|
|
690
|
+
},
|
|
691
|
+
"Failed to save message; will retry on next sync",
|
|
692
|
+
);
|
|
693
|
+
return { kind: "failed", uid: msg.uid };
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private async saveMessage(
|
|
698
|
+
mailboxId: string,
|
|
699
|
+
accountId: string,
|
|
700
|
+
accountConfigId: string,
|
|
701
|
+
msg: ImapMessage,
|
|
702
|
+
): Promise<SaveMessageResult | null> {
|
|
703
|
+
if (!msg.envelope) return null;
|
|
704
|
+
|
|
705
|
+
// Store envelope to preserve narrowing in closures
|
|
706
|
+
const envelope = msg.envelope;
|
|
707
|
+
|
|
708
|
+
const messageId = deriveMessageIdFromSource(accountId, {
|
|
709
|
+
messageId: envelope.messageId,
|
|
710
|
+
uid: msg.uid,
|
|
711
|
+
mailboxId,
|
|
712
|
+
date: envelope.date,
|
|
713
|
+
subject: envelope.subject,
|
|
714
|
+
fromMailbox: envelope.from?.[0]?.mailbox,
|
|
715
|
+
fromHost: envelope.from?.[0]?.host,
|
|
716
|
+
});
|
|
717
|
+
const envelopeId = deriveEnvelopeId(messageId);
|
|
718
|
+
const rootBodyPartId = deriveBodyPartId(messageId, ROOT_PART_PATH);
|
|
719
|
+
|
|
720
|
+
const internalDateMs = msg.internalDate.getTime();
|
|
721
|
+
const { value: sentDate, usedFallback: dateFellBack } = parseHeaderDate(
|
|
722
|
+
envelope.date,
|
|
723
|
+
internalDateMs,
|
|
724
|
+
);
|
|
725
|
+
if (dateFellBack) {
|
|
726
|
+
this.log.warn(
|
|
727
|
+
{
|
|
728
|
+
mailboxId,
|
|
729
|
+
messageId,
|
|
730
|
+
uid: msg.uid,
|
|
731
|
+
dateRaw: envelope.date,
|
|
732
|
+
},
|
|
733
|
+
"Unparseable Date header; fell back to IMAP internalDate",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Prepare all address save operations
|
|
738
|
+
const addressOps: Array<{
|
|
739
|
+
addresses: ImapAddress[] | undefined;
|
|
740
|
+
role: (typeof AddressRole)[keyof typeof AddressRole];
|
|
741
|
+
}> = [
|
|
742
|
+
{ addresses: envelope.from, role: AddressRole.From },
|
|
743
|
+
{ addresses: envelope.sender, role: AddressRole.Sender },
|
|
744
|
+
{ addresses: envelope.replyTo, role: AddressRole.ReplyTo },
|
|
745
|
+
{ addresses: envelope.to, role: AddressRole.To },
|
|
746
|
+
{ addresses: envelope.cc, role: AddressRole.Cc },
|
|
747
|
+
{ addresses: envelope.bcc, role: AddressRole.Bcc },
|
|
748
|
+
];
|
|
749
|
+
|
|
750
|
+
const bodyParts = buildBodyPartUpserts(msg.bodyStructure);
|
|
751
|
+
const hasAttachment = bodyParts.some(
|
|
752
|
+
(p) => !p.isMultipart && p.disposition === "attachment",
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
// Ownership of this messageId by the current mailbox: the row was created
|
|
756
|
+
// by this call, or an existing row already belongs to this mailbox. A
|
|
757
|
+
// conflict whose stored row points at a different mailbox is foreign-owned
|
|
758
|
+
// and must not feed this mailbox's watermark / body-sync (#634).
|
|
759
|
+
let owned = false;
|
|
760
|
+
|
|
761
|
+
// One unit of work for the whole message: on Postgres these repos are
|
|
762
|
+
// transaction-bound, so the Envelope, addresses, Message, BodyParts and
|
|
763
|
+
// ThreadMessage — and the transactional-outbox rows the Message write
|
|
764
|
+
// appends — all commit together. A throw anywhere rolls the whole set
|
|
765
|
+
// back, so a failed save never strands a Message without its Envelope
|
|
766
|
+
// (#1072). Writes run in sequence: a single transaction serialises on one
|
|
767
|
+
// connection, and it lets the Envelope land before the Message, with the
|
|
768
|
+
// ThreadMessage written last so the list path never anchors on a
|
|
769
|
+
// ThreadMessage whose Message does not yet exist (#1209).
|
|
770
|
+
await this.unitOfWork.transaction(async (repos) => {
|
|
771
|
+
await repos.envelope.upsertEnvelope({
|
|
772
|
+
envelopeId,
|
|
773
|
+
messageId,
|
|
774
|
+
dateValue: sentDate,
|
|
775
|
+
dateRaw: envelope.date,
|
|
776
|
+
subject: envelope.subject,
|
|
777
|
+
messageIdValue: envelope.messageId,
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
for (const { addresses, role } of addressOps) {
|
|
781
|
+
await this.saveAddresses(
|
|
782
|
+
repos.address,
|
|
783
|
+
messageId,
|
|
784
|
+
accountConfigId,
|
|
785
|
+
addresses,
|
|
786
|
+
role,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// IMAP returns BODYSTRUCTURE in the same FETCH that returns the
|
|
791
|
+
// envelope, so persisting BodyParts here is "free" — no extra round-trip.
|
|
792
|
+
if (bodyParts.length > 0) {
|
|
793
|
+
await repos.envelope.upsertBodyParts(messageId, bodyParts);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const { item, created } = await repos.message.upsertWithStatus({
|
|
797
|
+
messageId,
|
|
798
|
+
mailboxId,
|
|
799
|
+
uid: msg.uid,
|
|
800
|
+
sequenceNumber: msg.seq,
|
|
801
|
+
rfc822Size: msg.size ?? 0,
|
|
802
|
+
internalDate: msg.internalDate.getTime(),
|
|
803
|
+
envelopeId,
|
|
804
|
+
rootBodyPartId,
|
|
805
|
+
});
|
|
806
|
+
owned = created || item.mailboxId === mailboxId;
|
|
807
|
+
|
|
808
|
+
await this.createThreadForMessage(
|
|
809
|
+
repos.threadMessage,
|
|
810
|
+
messageId,
|
|
811
|
+
mailboxId,
|
|
812
|
+
accountId,
|
|
813
|
+
accountConfigId,
|
|
814
|
+
msg.uid,
|
|
815
|
+
msg.internalDate.getTime(),
|
|
816
|
+
sentDate,
|
|
817
|
+
envelope,
|
|
818
|
+
msg.flags,
|
|
819
|
+
msg.references,
|
|
820
|
+
hasAttachment,
|
|
821
|
+
);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
return { messageId, uid: msg.uid, owned };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
private async saveAddresses(
|
|
828
|
+
addressService: IAddressRepository,
|
|
829
|
+
messageId: string,
|
|
830
|
+
accountConfigId: string,
|
|
831
|
+
addresses: ImapAddress[] | undefined,
|
|
832
|
+
role: (typeof AddressRole)[keyof typeof AddressRole],
|
|
833
|
+
) {
|
|
834
|
+
if (!addresses) return;
|
|
835
|
+
|
|
836
|
+
// Pre-compute address data with order indices, filtering valid addresses
|
|
837
|
+
const addressData: Array<{
|
|
838
|
+
localPart: string;
|
|
839
|
+
domain: string;
|
|
840
|
+
displayName: string;
|
|
841
|
+
order: number;
|
|
842
|
+
}> = [];
|
|
843
|
+
|
|
844
|
+
for (let i = 0; i < addresses.length; i++) {
|
|
845
|
+
const addr = addresses[i];
|
|
846
|
+
if (!isParseableEmailAddress(addr)) continue;
|
|
847
|
+
addressData.push({
|
|
848
|
+
localPart: addr.mailbox,
|
|
849
|
+
domain: addr.host,
|
|
850
|
+
displayName: addr.name || "",
|
|
851
|
+
order: i,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
for (const { localPart, domain, displayName, order } of addressData) {
|
|
856
|
+
const normalizedEmail = `${localPart}@${domain}`.toLowerCase();
|
|
857
|
+
const normalizedCompound = `${displayName.toLowerCase()} ${normalizedEmail}`;
|
|
858
|
+
|
|
859
|
+
const addressId = deriveAddressId(accountConfigId, normalizedEmail);
|
|
860
|
+
|
|
861
|
+
const envelopeAddressId = deriveEnvelopeAddressId(messageId, role, order);
|
|
862
|
+
|
|
863
|
+
await addressService.upsertAddress({
|
|
864
|
+
addressId,
|
|
865
|
+
accountConfigId,
|
|
866
|
+
localPart,
|
|
867
|
+
domain,
|
|
868
|
+
normalizedEmail,
|
|
869
|
+
normalizedCompound,
|
|
870
|
+
displayName,
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
await addressService.upsertEnvelopeAddress({
|
|
874
|
+
envelopeAddressId,
|
|
875
|
+
messageId,
|
|
876
|
+
addressId,
|
|
877
|
+
displayName,
|
|
878
|
+
normalizedEmail,
|
|
879
|
+
addressRole: role,
|
|
880
|
+
addressOrder: order,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Create or update Thread and ThreadMessage for a synced message.
|
|
887
|
+
*
|
|
888
|
+
* Thread ID derivation (RFC 2822 compliant):
|
|
889
|
+
* 1. If References header exists, use the FIRST entry as thread root
|
|
890
|
+
* (References format: <root> <parent1> ... <direct-parent>)
|
|
891
|
+
* 2. Fall back to In-Reply-To if no References
|
|
892
|
+
* 3. Fall back to Message-ID (this message is a thread root)
|
|
893
|
+
*
|
|
894
|
+
* This ensures proper threading even when messages arrive out of order.
|
|
895
|
+
*/
|
|
896
|
+
private async createThreadForMessage(
|
|
897
|
+
threadMessageService: IThreadMessageRepository,
|
|
898
|
+
messageId: string,
|
|
899
|
+
mailboxId: string,
|
|
900
|
+
accountId: string,
|
|
901
|
+
accountConfigId: string,
|
|
902
|
+
uid: number,
|
|
903
|
+
internalDate: number,
|
|
904
|
+
sentDate: number,
|
|
905
|
+
envelope: ImapEnvelope,
|
|
906
|
+
flags: string[],
|
|
907
|
+
references?: string[],
|
|
908
|
+
hasAttachment = false,
|
|
909
|
+
): Promise<void> {
|
|
910
|
+
// Determine the thread root Message-ID
|
|
911
|
+
let rootMessageIdHeader: string;
|
|
912
|
+
|
|
913
|
+
if (references && references.length > 0) {
|
|
914
|
+
// References header exists - first entry is the thread root (RFC 2822)
|
|
915
|
+
rootMessageIdHeader = references[0];
|
|
916
|
+
} else if (envelope.inReplyTo) {
|
|
917
|
+
// No References, but has In-Reply-To - use as thread root
|
|
918
|
+
// (This is a reply to a single message, which becomes the root)
|
|
919
|
+
rootMessageIdHeader = envelope.inReplyTo;
|
|
920
|
+
} else if (isValidMessageId(envelope.messageId)) {
|
|
921
|
+
// No References, no In-Reply-To - this message is a thread root
|
|
922
|
+
rootMessageIdHeader = envelope.messageId;
|
|
923
|
+
} else {
|
|
924
|
+
// No usable header (missing, empty, or a "<>" delivery-failure
|
|
925
|
+
// placeholder). Fall back to the always-present internal messageId so
|
|
926
|
+
// this message becomes a standalone thread-of-one. Distinct headerless
|
|
927
|
+
// messages keep distinct ids, so they never collide into one bogus
|
|
928
|
+
// thread, and every persisted Message gets exactly one ThreadMessage.
|
|
929
|
+
rootMessageIdHeader = messageId;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Derive threadId from the root Message-ID (deterministic)
|
|
933
|
+
const threadId = deriveThreadId(accountId, rootMessageIdHeader);
|
|
934
|
+
|
|
935
|
+
// Check if message is read based on IMAP flags
|
|
936
|
+
const isRead = flags.includes("\\Seen");
|
|
937
|
+
|
|
938
|
+
// Extract sender info. When the server could not parse the From address,
|
|
939
|
+
// omit fromEmail rather than persist a fabricated string — a display name
|
|
940
|
+
// may still be present and useful, so keep it.
|
|
941
|
+
const fromAddr = envelope.from?.[0];
|
|
942
|
+
const fromEmail = isParseableEmailAddress(fromAddr)
|
|
943
|
+
? `${fromAddr?.mailbox}@${fromAddr?.host}`.toLowerCase()
|
|
944
|
+
: undefined;
|
|
945
|
+
const fromName = fromAddr?.name;
|
|
946
|
+
|
|
947
|
+
// Calculate reference order (position in the thread chain)
|
|
948
|
+
// references.length gives the position since References = [root, parent1, parent2, ...]
|
|
949
|
+
const referenceOrder = references?.length ?? (envelope.inReplyTo ? 1 : 0);
|
|
950
|
+
|
|
951
|
+
// Create ThreadMessage linking message to thread
|
|
952
|
+
await threadMessageService
|
|
953
|
+
.create({
|
|
954
|
+
threadId,
|
|
955
|
+
messageId,
|
|
956
|
+
accountConfigId,
|
|
957
|
+
mailboxId,
|
|
958
|
+
uid,
|
|
959
|
+
messageIdHeader: envelope.messageId,
|
|
960
|
+
inReplyTo: envelope.inReplyTo,
|
|
961
|
+
referenceOrder,
|
|
962
|
+
fromEmail,
|
|
963
|
+
fromName,
|
|
964
|
+
subject: envelope.subject,
|
|
965
|
+
internalDate,
|
|
966
|
+
sentDate,
|
|
967
|
+
isRead,
|
|
968
|
+
isDeleted: false,
|
|
969
|
+
hasAttachment,
|
|
970
|
+
hasStars: false,
|
|
971
|
+
})
|
|
972
|
+
.catch((error: unknown) => {
|
|
973
|
+
// Ignore conflict errors (idempotent create)
|
|
974
|
+
if (
|
|
975
|
+
(error as { name?: string })?.name === "CreateFailedConflictError"
|
|
976
|
+
) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
throw error;
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Translate the IMAP BODYSTRUCTURE for a single message into a list of
|
|
986
|
+
* `BodyPartUpsertInput`s ready for `EnvelopeService.upsertBodyParts`.
|
|
987
|
+
* Returns an empty list when the server didn't return BODYSTRUCTURE
|
|
988
|
+
* (some unusual messages, or older test fixtures).
|
|
989
|
+
*/
|
|
990
|
+
const buildBodyPartUpserts = (
|
|
991
|
+
bodyStructure: ImapBodyStructure | undefined,
|
|
992
|
+
): BodyPartUpsertInput[] => {
|
|
993
|
+
if (!bodyStructure) return [];
|
|
994
|
+
return walkMimeStructure(bodyStructure).map((part) => ({
|
|
995
|
+
partPath: part.partPath,
|
|
996
|
+
parentPartPath: part.parentPartPath,
|
|
997
|
+
mediaType: part.mediaType,
|
|
998
|
+
mediaSubtype: part.mediaSubtype,
|
|
999
|
+
transferEncoding: part.transferEncoding,
|
|
1000
|
+
sizeOctets: part.sizeOctets,
|
|
1001
|
+
isMultipart: part.isMultipart,
|
|
1002
|
+
parameters: part.parameters,
|
|
1003
|
+
...(part.contentId !== undefined ? { contentId: part.contentId } : {}),
|
|
1004
|
+
...(part.contentDescription !== undefined
|
|
1005
|
+
? { contentDescription: part.contentDescription }
|
|
1006
|
+
: {}),
|
|
1007
|
+
...(part.lineCount !== undefined ? { lineCount: part.lineCount } : {}),
|
|
1008
|
+
...(part.md5Hash !== undefined ? { md5Hash: part.md5Hash } : {}),
|
|
1009
|
+
...(part.disposition !== undefined
|
|
1010
|
+
? { disposition: part.disposition }
|
|
1011
|
+
: {}),
|
|
1012
|
+
...(part.dispositionFilename !== undefined
|
|
1013
|
+
? { dispositionFilename: part.dispositionFilename }
|
|
1014
|
+
: {}),
|
|
1015
|
+
...(part.language !== undefined ? { language: part.language } : {}),
|
|
1016
|
+
...(part.location !== undefined ? { location: part.location } : {}),
|
|
1017
|
+
...(part.multipartSubtype !== undefined
|
|
1018
|
+
? { multipartSubtype: part.multipartSubtype }
|
|
1019
|
+
: {}),
|
|
1020
|
+
}));
|
|
1021
|
+
};
|