@nocobase/app-plugin-mail 1.0.0-beta.13 → 1.0.0-beta.14
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/CHANGELOG.md +22 -0
- package/README.md +8 -0
- package/dist/database/migrations/.manifest.json +4 -0
- package/dist/database/migrations/202610250001_mail_imap_message_locations.d.ts +4 -0
- package/dist/database/migrations/202610250001_mail_imap_message_locations.d.ts.map +1 -0
- package/dist/database/migrations/202610250001_mail_imap_message_locations.js +297 -0
- package/dist/database/migrations/202610250001_mail_imap_message_locations.js.map +1 -0
- package/dist/server/adapters/imap-smtp/adapter.d.ts +60 -3
- package/dist/server/adapters/imap-smtp/adapter.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/adapter.js +774 -83
- package/dist/server/adapters/imap-smtp/adapter.js.map +1 -1
- package/dist/server/adapters/imap-smtp/constants.d.ts +6 -0
- package/dist/server/adapters/imap-smtp/constants.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/constants.js +6 -0
- package/dist/server/adapters/imap-smtp/constants.js.map +1 -1
- package/dist/server/adapters/imap-smtp/content.d.ts +11 -0
- package/dist/server/adapters/imap-smtp/content.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/content.js +82 -3
- package/dist/server/adapters/imap-smtp/content.js.map +1 -1
- package/dist/server/adapters/imap-smtp/definition.d.ts +7 -0
- package/dist/server/adapters/imap-smtp/definition.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/definition.js +19 -11
- package/dist/server/adapters/imap-smtp/definition.js.map +1 -1
- package/dist/server/adapters/imap-smtp/locators.d.ts +7 -0
- package/dist/server/adapters/imap-smtp/locators.d.ts.map +1 -1
- package/dist/server/adapters/imap-smtp/locators.js +53 -3
- package/dist/server/adapters/imap-smtp/locators.js.map +1 -1
- package/dist/server/adapters/imap-smtp/types.d.ts +87 -0
- package/dist/server/adapters/imap-smtp/types.d.ts.map +1 -1
- package/dist/server/operations/message-mutations.d.ts.map +1 -1
- package/dist/server/operations/message-mutations.js +57 -5
- package/dist/server/operations/message-mutations.js.map +1 -1
- package/dist/server/operations/sync-mailbox.d.ts.map +1 -1
- package/dist/server/operations/sync-mailbox.js +26 -10
- package/dist/server/operations/sync-mailbox.js.map +1 -1
- package/dist/server/providers/mail-builtins.d.ts.map +1 -1
- package/dist/server/providers/mail-builtins.js +6 -2
- package/dist/server/providers/mail-builtins.js.map +1 -1
- package/dist/server/store/attachments.d.ts.map +1 -1
- package/dist/server/store/attachments.js +3 -0
- package/dist/server/store/attachments.js.map +1 -1
- package/dist/server/store/message-writes.d.ts +24 -1
- package/dist/server/store/message-writes.d.ts.map +1 -1
- package/dist/server/store/message-writes.js +357 -1
- package/dist/server/store/message-writes.js.map +1 -1
- package/dist/server/store/messages.d.ts.map +1 -1
- package/dist/server/store/messages.js +11 -3
- package/dist/server/store/messages.js.map +1 -1
- package/dist/server/store/rows.d.ts +20 -0
- package/dist/server/store/rows.d.ts.map +1 -1
- package/dist/server/store/sync.d.ts +7 -1
- package/dist/server/store/sync.d.ts.map +1 -1
- package/dist/server/store/sync.js +333 -7
- package/dist/server/store/sync.js.map +1 -1
- package/package.json +14 -13
|
@@ -2,10 +2,10 @@ import MailComposer from 'nodemailer/lib/mail-composer/index.js';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { simpleParser } from 'mailparser';
|
|
4
4
|
import { Readable, addAbortSignal } from 'node:stream';
|
|
5
|
-
import { attachmentParts, bodyParts, readBody, metadataQuery, attachmentMetadata, } from './content.js';
|
|
6
|
-
import { IMAP_SMTP_CAPABILITIES, MAX_IMAP_BODY_BYTES, MAX_IMAP_DETAIL_BODY_BYTES, } from './constants.js';
|
|
5
|
+
import { attachmentParts, bodyParts, readBody, readRawSource, rawComparable, metadataQuery, attachmentMetadata, MAX_HEADER_BYTES, } from './content.js';
|
|
6
|
+
import { IMAP_SMTP_CAPABILITIES, MAX_IMAP_BODY_BYTES, MAX_IMAP_DETAIL_BODY_BYTES, MAX_IMAP_PROVIDER_ID_LENGTH, MAX_IMAP_RAW_COMPARE_BUDGET, MAX_IMAP_RAW_COMPARE_BYTES, MAX_IMAP_VERIFY_ATTEMPTS, MAX_IMAP_VERIFY_STATE_BYTES, } from './constants.js';
|
|
7
7
|
import { createImapClient, createSmtpTransport } from './connection.js';
|
|
8
|
-
import { encodeHistoryCursor, encodeMessageLocator, folderCursor, parseHistoryCursor,
|
|
8
|
+
import { compressUids, encodeCopyLocator, encodeHistoryCursor, encodeMessageLocator, folderCursor, parseHistoryCursor, parseSyncCursor, rangeFor, requireAttachmentLocator, requireMessageLocator, syncCursor, } from './locators.js';
|
|
9
9
|
import { classifyFolderType, folderTypeFromPath } from './folders.js';
|
|
10
10
|
function classifyConfiguredFolder(mailbox, sentFolder) {
|
|
11
11
|
if (sentFolder && mailbox.path === sentFolder)
|
|
@@ -15,18 +15,104 @@ function classifyConfiguredFolder(mailbox, sentFolder) {
|
|
|
15
15
|
import { addresses, envelopeAddress, envelopeAddresses, preview, references, toHeader, toIsoDate, } from './normalize.js';
|
|
16
16
|
import { classifyError, failure, throwIfAborted } from './errors.js';
|
|
17
17
|
export class ImapSmtpAdapter {
|
|
18
|
+
context;
|
|
18
19
|
config;
|
|
20
|
+
account;
|
|
19
21
|
credential;
|
|
22
|
+
locations;
|
|
23
|
+
protocol;
|
|
24
|
+
operation;
|
|
20
25
|
identity;
|
|
21
26
|
capabilities = IMAP_SMTP_CAPABILITIES;
|
|
22
27
|
folderTypes = new Map();
|
|
23
28
|
imapClient;
|
|
24
29
|
smtpTransport;
|
|
25
|
-
|
|
30
|
+
// Evidence and byte budget of the current step only; never reused across steps.
|
|
31
|
+
copies = [];
|
|
32
|
+
changes = [];
|
|
33
|
+
cleanup = false;
|
|
34
|
+
verificationIncomplete = false;
|
|
35
|
+
verificationCopies = [];
|
|
36
|
+
syncRun;
|
|
37
|
+
rawBudget = MAX_IMAP_RAW_COMPARE_BUDGET;
|
|
38
|
+
rawCache = new Map();
|
|
39
|
+
constructor(context, config, account, credential, locations, run, protocol = Object.freeze({
|
|
40
|
+
imap: createImapClient,
|
|
41
|
+
smtp: createSmtpTransport,
|
|
42
|
+
expungeUid,
|
|
43
|
+
}), operation) {
|
|
44
|
+
this.context = context;
|
|
26
45
|
this.config = config;
|
|
46
|
+
this.account = account;
|
|
27
47
|
this.credential = credential;
|
|
28
|
-
this.
|
|
29
|
-
this.
|
|
48
|
+
this.locations = locations;
|
|
49
|
+
this.protocol = protocol;
|
|
50
|
+
this.operation = operation;
|
|
51
|
+
if (locations && locations.accountId !== account.id)
|
|
52
|
+
throw new Error('The IMAP persistence belongs to another account.');
|
|
53
|
+
if (operation && operation.accountId !== account.id)
|
|
54
|
+
throw new Error('The IMAP operation belongs to another account.');
|
|
55
|
+
this.operation = operation && Object.freeze({ ...operation });
|
|
56
|
+
this.config = Object.freeze({
|
|
57
|
+
...config,
|
|
58
|
+
imap: Object.freeze({ ...config.imap }),
|
|
59
|
+
smtp: Object.freeze({ ...config.smtp }),
|
|
60
|
+
});
|
|
61
|
+
this.account = Object.freeze({
|
|
62
|
+
...account,
|
|
63
|
+
provider: Object.freeze({ ...account.provider }),
|
|
64
|
+
});
|
|
65
|
+
this.credential = Object.freeze({ ...credential });
|
|
66
|
+
this.syncRun =
|
|
67
|
+
run &&
|
|
68
|
+
Object.freeze({ ...run, policy: Object.freeze({ ...run.policy }) });
|
|
69
|
+
this.protocol = Object.freeze({ ...protocol });
|
|
70
|
+
this.identity = this.account.provider;
|
|
71
|
+
this.smtpTransport = this.protocol.smtp(this.config.smtp, this.credential);
|
|
72
|
+
}
|
|
73
|
+
forSync(run) {
|
|
74
|
+
if (!this.locations)
|
|
75
|
+
throw new Error('IMAP location persistence is not configured.');
|
|
76
|
+
if (this.operation)
|
|
77
|
+
throw new Error('An IMAP message operation cannot be reused for synchronization.');
|
|
78
|
+
if (run.accountId !== this.account.id)
|
|
79
|
+
throw new Error('The IMAP sync account changed.');
|
|
80
|
+
return new ImapSmtpAdapter(this.context, this.config, this.account, this.credential, this.locations, run, this.protocol);
|
|
81
|
+
}
|
|
82
|
+
async forMessage(messageId, providerMessageId, operation) {
|
|
83
|
+
if (!this.locations)
|
|
84
|
+
throw new Error('IMAP location persistence is not configured.');
|
|
85
|
+
const bound = await this.locations.bindMessage(messageId, providerMessageId, operation);
|
|
86
|
+
return new ImapSmtpAdapter(this.context, this.config, this.account, this.credential, bound.persistence, undefined, this.protocol, bound.snapshot);
|
|
87
|
+
}
|
|
88
|
+
/** True when this instance records message copies in the account's location table. */
|
|
89
|
+
get persistsLocations() {
|
|
90
|
+
return this.locations !== undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Commit a sync step together with the location evidence this instance gathered for it. */
|
|
93
|
+
async commitSyncStep(input) {
|
|
94
|
+
if (!this.locations)
|
|
95
|
+
throw new Error('IMAP location persistence is not configured.');
|
|
96
|
+
if (!this.syncRun)
|
|
97
|
+
throw new Error('An IMAP sync commit requires its immutable run snapshot.');
|
|
98
|
+
const ids = new Set(input.messages.map((message) => message.providerMessageId));
|
|
99
|
+
if (this.syncRun &&
|
|
100
|
+
(input.run.id !== this.syncRun.id ||
|
|
101
|
+
input.run.accountId !== this.syncRun.accountId ||
|
|
102
|
+
input.run.phase !== this.syncRun.phase ||
|
|
103
|
+
input.run.revision !== this.syncRun.revision ||
|
|
104
|
+
input.run.leaseToken !== this.syncRun.leaseToken))
|
|
105
|
+
throw new Error('The IMAP sync operation snapshot changed.');
|
|
106
|
+
const incomplete = this.verificationIncomplete;
|
|
107
|
+
const evidence = {
|
|
108
|
+
copies: this.copies.filter((copy) => copy.flagsOnly || ids.has(copy.providerMessageId)),
|
|
109
|
+
changes: this.changes,
|
|
110
|
+
cleanup: this.cleanup,
|
|
111
|
+
};
|
|
112
|
+
this.beginStep();
|
|
113
|
+
return this.locations.commitSyncStep(incomplete
|
|
114
|
+
? { ...input, status: 'partial', createNextTask: false }
|
|
115
|
+
: input, evidence);
|
|
30
116
|
}
|
|
31
117
|
async listFolders(input) {
|
|
32
118
|
try {
|
|
@@ -63,6 +149,7 @@ export class ImapSmtpAdapter {
|
|
|
63
149
|
}
|
|
64
150
|
}
|
|
65
151
|
async listMessages(input) {
|
|
152
|
+
this.beginStep();
|
|
66
153
|
try {
|
|
67
154
|
throwIfAborted(input.signal);
|
|
68
155
|
const folders = input.providerFolderIds?.length
|
|
@@ -150,13 +237,14 @@ export class ImapSmtpAdapter {
|
|
|
150
237
|
upperUid = start - 1;
|
|
151
238
|
}
|
|
152
239
|
}
|
|
153
|
-
for (const
|
|
240
|
+
for (const copy of await this.fetchMessages(folder, String(mailbox.uidValidity), selected, input.signal)) {
|
|
241
|
+
const { receivedAt } = copy.message;
|
|
154
242
|
if (input.receivedAfter &&
|
|
155
|
-
|
|
156
|
-
Date.parse(
|
|
243
|
+
receivedAt &&
|
|
244
|
+
Date.parse(receivedAt) < Date.parse(input.receivedAfter)) {
|
|
157
245
|
continue;
|
|
158
246
|
}
|
|
159
|
-
messages.push(
|
|
247
|
+
messages.push(copy);
|
|
160
248
|
}
|
|
161
249
|
if (upperUid !== undefined && upperUid < 1) {
|
|
162
250
|
folderIndex += 1;
|
|
@@ -169,7 +257,7 @@ export class ImapSmtpAdapter {
|
|
|
169
257
|
return {
|
|
170
258
|
ok: true,
|
|
171
259
|
value: {
|
|
172
|
-
messages,
|
|
260
|
+
messages: await this.assignIdentities(messages, input.signal),
|
|
173
261
|
nextCursor,
|
|
174
262
|
syncCursor: input.cursor ? undefined : input.baselineCursor,
|
|
175
263
|
},
|
|
@@ -180,6 +268,7 @@ export class ImapSmtpAdapter {
|
|
|
180
268
|
}
|
|
181
269
|
}
|
|
182
270
|
async listChanges(input) {
|
|
271
|
+
this.beginStep();
|
|
183
272
|
try {
|
|
184
273
|
throwIfAborted(input.signal);
|
|
185
274
|
let previous;
|
|
@@ -200,78 +289,40 @@ export class ImapSmtpAdapter {
|
|
|
200
289
|
const mailboxes = this.selectableMailboxes(await this.listMailboxes());
|
|
201
290
|
const nextFolders = {};
|
|
202
291
|
const messages = [];
|
|
203
|
-
const deletedProviderMessageIds = new Set();
|
|
204
|
-
const knownByFolder = new Map();
|
|
205
|
-
for (const providerMessageId of input.knownProviderMessageIds ?? []) {
|
|
206
|
-
const locator = parseMessageLocator(providerMessageId);
|
|
207
|
-
if (!locator)
|
|
208
|
-
continue;
|
|
209
|
-
const known = knownByFolder.get(locator.folder) ?? new Set();
|
|
210
|
-
known.add(providerMessageId);
|
|
211
|
-
knownByFolder.set(locator.folder, known);
|
|
212
|
-
}
|
|
213
292
|
let hasMore = false;
|
|
214
293
|
const limit = Math.max(1, input.limit);
|
|
215
294
|
for (const mailbox of mailboxes) {
|
|
216
295
|
throwIfAborted(input.signal);
|
|
217
296
|
const current = await this.folderState(mailbox.path, mailbox);
|
|
218
297
|
const old = previous.folders[mailbox.path];
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
ok: false,
|
|
222
|
-
error: {
|
|
223
|
-
code: 'IMAP_SYNC_CURSOR_INVALID',
|
|
224
|
-
message: `The IMAP UIDVALIDITY changed for ${mailbox.path}.`,
|
|
225
|
-
category: 'provider',
|
|
226
|
-
retryable: false,
|
|
227
|
-
},
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
const start = old?.uidNext ?? 1;
|
|
298
|
+
const sameGeneration = old?.uidValidity === current.uidValidity;
|
|
299
|
+
const start = sameGeneration ? old.uidNext : 1;
|
|
231
300
|
const canFetch = messages.length < limit;
|
|
232
301
|
const end = canFetch
|
|
233
302
|
? Math.min(current.uidNext - 1, start + limit - messages.length - 1)
|
|
234
303
|
: start - 1;
|
|
235
|
-
let nextUid = old
|
|
236
|
-
|
|
237
|
-
if (start <= end || knownIds?.size) {
|
|
304
|
+
let nextUid = sameGeneration ? old.uidNext : 1;
|
|
305
|
+
if (start <= end) {
|
|
238
306
|
const client = await this.imap();
|
|
239
307
|
const selected = await client.mailboxOpen(mailbox.path, {
|
|
240
308
|
readOnly: true,
|
|
241
309
|
});
|
|
242
310
|
if (String(selected.uidValidity) !== current.uidValidity)
|
|
243
311
|
return failure('IMAP_SYNC_CURSOR_INVALID', 'Mailbox generation changed before fetching.', 'provider', false);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
if (start <= end) {
|
|
260
|
-
const fetched = await this.fetchMessages(mailbox.path, current.uidValidity, rangeFor(start, end), input.signal);
|
|
261
|
-
messages.push(...fetched);
|
|
262
|
-
// Complete the bounded UID range before advancing, regardless of response order or holes.
|
|
263
|
-
nextUid = end + 1;
|
|
264
|
-
if (fetched.length === 0 && nextUid < current.uidNext) {
|
|
265
|
-
// Some servers assign very large, sparse UIDs. Jump to the next
|
|
266
|
-
// existing message instead of scheduling every empty UID range.
|
|
267
|
-
const remaining = await client.search({ uid: rangeFor(nextUid, current.uidNext - 1) }, { uid: true });
|
|
268
|
-
const lowerBound = nextUid;
|
|
269
|
-
nextUid = current.uidNext;
|
|
270
|
-
if (Array.isArray(remaining)) {
|
|
271
|
-
for (const uid of remaining) {
|
|
272
|
-
if (uid >= lowerBound && uid < nextUid)
|
|
273
|
-
nextUid = uid;
|
|
274
|
-
}
|
|
312
|
+
const fetched = await this.fetchMessages(mailbox.path, current.uidValidity, rangeFor(start, end), input.signal);
|
|
313
|
+
messages.push(...fetched);
|
|
314
|
+
// Complete the bounded UID range before advancing, regardless of response order or holes.
|
|
315
|
+
nextUid = end + 1;
|
|
316
|
+
if (fetched.length === 0 && nextUid < current.uidNext) {
|
|
317
|
+
// Some servers assign very large, sparse UIDs. Jump to the next
|
|
318
|
+
// existing message instead of scheduling every empty UID range.
|
|
319
|
+
const remaining = await client.search({ uid: rangeFor(nextUid, current.uidNext - 1) }, { uid: true });
|
|
320
|
+
const lowerBound = nextUid;
|
|
321
|
+
nextUid = current.uidNext;
|
|
322
|
+
if (Array.isArray(remaining)) {
|
|
323
|
+
for (const uid of remaining) {
|
|
324
|
+
if (uid >= lowerBound && uid < nextUid)
|
|
325
|
+
nextUid = uid;
|
|
275
326
|
}
|
|
276
327
|
}
|
|
277
328
|
}
|
|
@@ -286,12 +337,36 @@ export class ImapSmtpAdapter {
|
|
|
286
337
|
uidNext: nextUid,
|
|
287
338
|
};
|
|
288
339
|
}
|
|
340
|
+
let verify;
|
|
341
|
+
if (this.locations) {
|
|
342
|
+
if (hasMore || messages.length > 0) {
|
|
343
|
+
// New copies are committed first; verification restarts afterwards.
|
|
344
|
+
verify = previous.verify && {
|
|
345
|
+
attempts: previous.verify.attempts,
|
|
346
|
+
excluded: previous.verify.excluded,
|
|
347
|
+
};
|
|
348
|
+
hasMore = true;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
const result = await this.verifyFolders(mailboxes, nextFolders, previous.verify, input.signal);
|
|
352
|
+
if (result === 'generation-changed')
|
|
353
|
+
return failure('IMAP_SYNC_CURSOR_INVALID', 'Mailbox generation changed during verification.', 'provider', false);
|
|
354
|
+
messages.push(...this.verificationCopies);
|
|
355
|
+
hasMore = !result.done;
|
|
356
|
+
verify = result.verify;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
289
359
|
return {
|
|
290
360
|
ok: true,
|
|
291
361
|
value: {
|
|
292
|
-
messages,
|
|
293
|
-
|
|
294
|
-
|
|
362
|
+
messages: await this.assignIdentities(messages, input.signal),
|
|
363
|
+
// Copies leave through location evidence; one missing UID never deletes a message.
|
|
364
|
+
deletedProviderMessageIds: [],
|
|
365
|
+
nextCursor: syncCursor({
|
|
366
|
+
version: 1,
|
|
367
|
+
folders: nextFolders,
|
|
368
|
+
...(verify ? { verify } : {}),
|
|
369
|
+
}),
|
|
295
370
|
hasMore,
|
|
296
371
|
},
|
|
297
372
|
};
|
|
@@ -303,6 +378,22 @@ export class ImapSmtpAdapter {
|
|
|
303
378
|
async getMessage(providerMessageId, signal) {
|
|
304
379
|
try {
|
|
305
380
|
throwIfAborted(signal);
|
|
381
|
+
if (this.locations) {
|
|
382
|
+
// Try each verified copy; a stale copy is skipped, never reported as deleted.
|
|
383
|
+
for (const location of await this.locations.listValidLocations(providerMessageId)) {
|
|
384
|
+
const message = await this.openCopy(location, metadataQuery());
|
|
385
|
+
if (!message)
|
|
386
|
+
continue;
|
|
387
|
+
return {
|
|
388
|
+
ok: true,
|
|
389
|
+
value: {
|
|
390
|
+
...(await this.normalizeMessage(location.folder, location.uidValidity, message, signal, MAX_IMAP_DETAIL_BODY_BYTES)),
|
|
391
|
+
providerMessageId,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return failure('IMAP_MESSAGE_NOT_FOUND', 'No verified IMAP copy of this message is available.', 'provider', false);
|
|
396
|
+
}
|
|
306
397
|
const locator = requireMessageLocator(providerMessageId);
|
|
307
398
|
const mailbox = await (await this.imap()).mailboxOpen(locator.folder, {
|
|
308
399
|
readOnly: true,
|
|
@@ -322,24 +413,72 @@ export class ImapSmtpAdapter {
|
|
|
322
413
|
return { ok: false, error: classifyError(error, 'IMAP_GET_MESSAGE') };
|
|
323
414
|
}
|
|
324
415
|
}
|
|
325
|
-
async getAttachment(
|
|
416
|
+
async getAttachment(providerMessageId, providerAttachmentId, signal) {
|
|
417
|
+
let release;
|
|
418
|
+
let streamed = false;
|
|
326
419
|
try {
|
|
327
420
|
throwIfAborted(signal);
|
|
328
421
|
const locator = requireAttachmentLocator(providerAttachmentId);
|
|
329
|
-
|
|
422
|
+
let storedCopy;
|
|
423
|
+
let expected;
|
|
424
|
+
if (this.locations) {
|
|
425
|
+
// The part index is only meaningful on the copy it was read from.
|
|
426
|
+
const [location] = await this.locations.findLocations([locator]);
|
|
427
|
+
if (location?.status !== 'valid' ||
|
|
428
|
+
location.providerMessageId !== providerMessageId)
|
|
429
|
+
return failure('IMAP_ATTACHMENT_NOT_FOUND', 'The IMAP copy holding this attachment is no longer available.', 'provider', false);
|
|
430
|
+
storedCopy = location;
|
|
431
|
+
expected = await this.locations.findAttachment(location, providerAttachmentId);
|
|
432
|
+
if (!expected)
|
|
433
|
+
return failure('IMAP_ATTACHMENT_NOT_FOUND', 'The stored attachment does not belong to this copy.', 'provider', false);
|
|
434
|
+
}
|
|
435
|
+
const lockedClient = await this.imap();
|
|
436
|
+
const lock = await lockedClient.getMailboxLock(locator.folder, {
|
|
437
|
+
readOnly: true,
|
|
438
|
+
});
|
|
439
|
+
let released = false;
|
|
440
|
+
release = () => {
|
|
441
|
+
if (!released) {
|
|
442
|
+
released = true;
|
|
443
|
+
lock.release();
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
const selected = lockedClient.mailbox;
|
|
447
|
+
if (!selected)
|
|
448
|
+
throw new Error('IMAP mailbox lock did not select the folder.');
|
|
330
449
|
if (String(selected.uidValidity) !== locator.uidValidity)
|
|
331
450
|
return failure('IMAP_SYNC_CURSOR_INVALID', 'Mailbox generation changed before downloading.', 'provider', false);
|
|
332
451
|
const client = await this.imap();
|
|
333
|
-
const message = await client.fetchOne(locator.uid, { uid: true, bodyStructure: true }, { uid: true });
|
|
334
|
-
|
|
452
|
+
const message = await client.fetchOne(locator.uid, { uid: true, bodyStructure: true, envelope: true }, { uid: true });
|
|
453
|
+
if (!message || message.uid !== locator.uid)
|
|
454
|
+
return failure('IMAP_ATTACHMENT_NOT_FOUND', 'The IMAP attachment copy changed.', 'provider', false);
|
|
455
|
+
const part = attachmentParts(message.bodyStructure)[locator.attachment];
|
|
335
456
|
if (!part)
|
|
336
457
|
return failure('IMAP_ATTACHMENT_NOT_FOUND', 'IMAP attachment was not found.', 'provider', false);
|
|
458
|
+
if (expected) {
|
|
459
|
+
const actual = attachmentMetadata(locator.folder, locator.uidValidity, locator.uid, part, locator.attachment);
|
|
460
|
+
if (actual.fileName !== expected.fileName ||
|
|
461
|
+
actual.contentType !== expected.contentType ||
|
|
462
|
+
actual.size !== expected.size ||
|
|
463
|
+
actual.contentId !== expected.contentId ||
|
|
464
|
+
actual.inline !== expected.inline ||
|
|
465
|
+
((storedCopy?.source === 'objectid' ||
|
|
466
|
+
storedCopy?.source === 'x-gm-msgid') &&
|
|
467
|
+
validNativeId(this.nativeSource(), message.emailId) !== undefined &&
|
|
468
|
+
(this.nativeSource() !== storedCopy.source ||
|
|
469
|
+
message.emailId !== storedCopy.providerMessageId)))
|
|
470
|
+
return failure('IMAP_ATTACHMENT_NOT_FOUND', 'The IMAP attachment metadata or identity changed.', 'provider', false);
|
|
471
|
+
}
|
|
337
472
|
const download = await client.download(locator.uid, part.part ?? '1', {
|
|
338
473
|
uid: true,
|
|
339
474
|
chunkSize: 64 * 1024,
|
|
340
475
|
});
|
|
341
476
|
if (!download.content)
|
|
342
477
|
return failure('IMAP_ATTACHMENT_NOT_FOUND', 'IMAP attachment was not found.', 'provider', false);
|
|
478
|
+
download.content.once('end', release);
|
|
479
|
+
download.content.once('error', release);
|
|
480
|
+
download.content.once('close', release);
|
|
481
|
+
streamed = true;
|
|
343
482
|
if (signal)
|
|
344
483
|
addAbortSignal(signal, download.content);
|
|
345
484
|
return {
|
|
@@ -363,6 +502,10 @@ export class ImapSmtpAdapter {
|
|
|
363
502
|
catch (error) {
|
|
364
503
|
return { ok: false, error: classifyError(error, 'IMAP_GET_ATTACHMENT') };
|
|
365
504
|
}
|
|
505
|
+
finally {
|
|
506
|
+
if (!streamed)
|
|
507
|
+
release?.();
|
|
508
|
+
}
|
|
366
509
|
}
|
|
367
510
|
async sendMessage(input) {
|
|
368
511
|
let submissionStarted = false;
|
|
@@ -483,21 +626,80 @@ export class ImapSmtpAdapter {
|
|
|
483
626
|
}
|
|
484
627
|
}
|
|
485
628
|
async setRead(providerMessageId, read, signal) {
|
|
486
|
-
|
|
629
|
+
if ((this.locations && !this.operation) ||
|
|
630
|
+
(this.operation &&
|
|
631
|
+
(this.operation.operation !== 'flags' ||
|
|
632
|
+
this.operation.providerMessageId !== providerMessageId)))
|
|
633
|
+
return failure('IMAP_OPERATION_MISMATCH', 'The IMAP operation does not match its immutable target.', 'configuration', false);
|
|
634
|
+
return this.locations
|
|
635
|
+
? this.updateCopyFlags(providerMessageId, '\\Seen', read, signal)
|
|
636
|
+
: this.updateFlags(providerMessageId, '\\Seen', read, signal);
|
|
487
637
|
}
|
|
488
638
|
async setStarred(providerMessageId, starred, signal) {
|
|
489
|
-
|
|
639
|
+
if ((this.locations && !this.operation) ||
|
|
640
|
+
(this.operation &&
|
|
641
|
+
(this.operation.operation !== 'flags' ||
|
|
642
|
+
this.operation.providerMessageId !== providerMessageId)))
|
|
643
|
+
return failure('IMAP_OPERATION_MISMATCH', 'The IMAP operation does not match its immutable target.', 'configuration', false);
|
|
644
|
+
return this.locations
|
|
645
|
+
? this.updateCopyFlags(providerMessageId, '\\Flagged', starred, signal)
|
|
646
|
+
: this.updateFlags(providerMessageId, '\\Flagged', starred, signal);
|
|
490
647
|
}
|
|
491
648
|
async deleteMessage(providerMessageId, permanently, signal) {
|
|
649
|
+
if ((this.locations && !this.operation) ||
|
|
650
|
+
(this.operation &&
|
|
651
|
+
(this.operation.operation !== 'delete' ||
|
|
652
|
+
this.operation.providerMessageId !== providerMessageId)))
|
|
653
|
+
return failure('IMAP_OPERATION_MISMATCH', 'The IMAP operation does not match its immutable target.', 'configuration', false);
|
|
492
654
|
if (!permanently) {
|
|
493
655
|
return failure('IMAP_SOFT_DELETE_UNSUPPORTED', 'This IMAP Provider cannot move messages to Trash. Delete them in your mail client instead.', 'configuration', false);
|
|
494
656
|
}
|
|
495
657
|
try {
|
|
496
658
|
throwIfAborted(signal);
|
|
497
|
-
const locator = requireMessageLocator(providerMessageId);
|
|
498
659
|
const client = await this.imap();
|
|
499
|
-
|
|
500
|
-
|
|
660
|
+
// Without UIDPLUS, ImapFlow falls back to a mailbox-wide EXPUNGE.
|
|
661
|
+
if (!client.capabilities?.has('UIDPLUS'))
|
|
662
|
+
return failure('IMAP_UIDPLUS_REQUIRED', 'This IMAP server cannot expunge a single message (UIDPLUS is missing). Delete it in your mail client instead.', 'configuration', false);
|
|
663
|
+
if (!this.locations) {
|
|
664
|
+
const locator = requireMessageLocator(providerMessageId);
|
|
665
|
+
const selected = await client.mailboxOpen(locator.folder);
|
|
666
|
+
if (String(selected.uidValidity) !== locator.uidValidity)
|
|
667
|
+
return this.copyChanged();
|
|
668
|
+
const target = await client.fetchOne(locator.uid, { uid: true }, { uid: true });
|
|
669
|
+
if (!target || target.uid !== locator.uid)
|
|
670
|
+
return this.copyChanged();
|
|
671
|
+
if (!(await client.messageFlagsAdd(String(locator.uid), ['\\Deleted'], {
|
|
672
|
+
uid: true,
|
|
673
|
+
})))
|
|
674
|
+
throw new Error('The IMAP server did not set the deletion flag.');
|
|
675
|
+
if (!client.capabilities.has('UIDPLUS'))
|
|
676
|
+
throw new Error('UIDPLUS became unavailable before expunging.');
|
|
677
|
+
await this.protocol.expungeUid(client, locator.uid);
|
|
678
|
+
const remaining = await client.search({ uid: String(locator.uid) }, { uid: true });
|
|
679
|
+
if (!Array.isArray(remaining) || remaining.includes(locator.uid))
|
|
680
|
+
throw new Error('The IMAP server still lists the deleted message.');
|
|
681
|
+
return { ok: true, value: undefined };
|
|
682
|
+
}
|
|
683
|
+
const locations = await this.locations.listValidLocations(providerMessageId);
|
|
684
|
+
if (locations.length === 0)
|
|
685
|
+
return failure('IMAP_MESSAGE_NOT_FOUND', 'No verified IMAP copy of this message is available.', 'provider', false);
|
|
686
|
+
for (const location of locations) {
|
|
687
|
+
throwIfAborted(signal);
|
|
688
|
+
if (!(await this.openCopy(location, { uid: true }, false)))
|
|
689
|
+
return this.copyChanged();
|
|
690
|
+
const uid = String(location.uid);
|
|
691
|
+
if (!(await client.messageFlagsAdd(uid, ['\\Deleted'], { uid: true })))
|
|
692
|
+
throw new Error('The IMAP server did not set the deletion flag.');
|
|
693
|
+
if (!client.capabilities.has('UIDPLUS'))
|
|
694
|
+
throw new Error('UIDPLUS became unavailable before expunging.');
|
|
695
|
+
// Never use messageDelete(): its post-STORE capability check can choose global EXPUNGE.
|
|
696
|
+
await this.protocol.expungeUid(client, location.uid);
|
|
697
|
+
const remaining = await client.search({ uid }, { uid: true });
|
|
698
|
+
if (!Array.isArray(remaining) || remaining.includes(location.uid))
|
|
699
|
+
throw new Error('The IMAP server still lists the deleted message.');
|
|
700
|
+
// Earlier copies stay committed if a later copy fails.
|
|
701
|
+
await this.locations.commitDeleted(location);
|
|
702
|
+
}
|
|
501
703
|
return { ok: true, value: undefined };
|
|
502
704
|
}
|
|
503
705
|
catch (error) {
|
|
@@ -532,10 +734,15 @@ export class ImapSmtpAdapter {
|
|
|
532
734
|
return { ok: false, error: classifyError(error, 'IMAP_UPDATE_FLAGS') };
|
|
533
735
|
}
|
|
534
736
|
}
|
|
737
|
+
closeImap() {
|
|
738
|
+
const client = this.imapClient;
|
|
739
|
+
this.imapClient = undefined;
|
|
740
|
+
client?.close();
|
|
741
|
+
}
|
|
535
742
|
async imap() {
|
|
536
743
|
if (this.imapClient)
|
|
537
744
|
return this.imapClient;
|
|
538
|
-
const client =
|
|
745
|
+
const client = this.protocol.imap(this.config.imap, this.credential);
|
|
539
746
|
await client.connect();
|
|
540
747
|
this.imapClient = client;
|
|
541
748
|
return client;
|
|
@@ -642,6 +849,9 @@ export class ImapSmtpAdapter {
|
|
|
642
849
|
async fetchMessages(folder, uidValidity, uids, signal) {
|
|
643
850
|
if (uids.length === 0)
|
|
644
851
|
return [];
|
|
852
|
+
const observed = this.locations
|
|
853
|
+
? await this.locations.listKnownLocations()
|
|
854
|
+
: [];
|
|
645
855
|
const metadata = [];
|
|
646
856
|
const client = await this.imap();
|
|
647
857
|
// Finish FETCH before issuing more IMAP commands: nested downloads deadlock.
|
|
@@ -654,12 +864,450 @@ export class ImapSmtpAdapter {
|
|
|
654
864
|
const messages = [];
|
|
655
865
|
for (const message of metadata) {
|
|
656
866
|
throwIfAborted(signal);
|
|
657
|
-
|
|
867
|
+
const normalized = await this.normalizeMessage(folder, uidValidity, message, signal);
|
|
868
|
+
messages.push({
|
|
869
|
+
message: normalized,
|
|
870
|
+
tuple: { folder, uidValidity, uid: message.uid },
|
|
871
|
+
observed: observed.find((location) => location.folder === folder &&
|
|
872
|
+
location.uidValidity === uidValidity &&
|
|
873
|
+
location.uid === message.uid),
|
|
874
|
+
emailId: message.emailId,
|
|
875
|
+
rfcMessageId: validRfcMessageId(normalized.internetMessageId),
|
|
876
|
+
structure: message.bodyStructure,
|
|
877
|
+
size: message.size,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
return messages;
|
|
881
|
+
}
|
|
882
|
+
beginStep() {
|
|
883
|
+
this.copies = [];
|
|
884
|
+
this.changes = [];
|
|
885
|
+
this.cleanup = false;
|
|
886
|
+
this.verificationIncomplete = false;
|
|
887
|
+
this.verificationCopies.length = 0;
|
|
888
|
+
this.rawBudget = MAX_IMAP_RAW_COMPARE_BUDGET;
|
|
889
|
+
this.rawCache.clear();
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Choose each copy's identity: its persisted location first, then the
|
|
893
|
+
* server's native ID, then an RFC Message-ID proven by identical MIME bytes,
|
|
894
|
+
* and otherwise the copy's own location.
|
|
895
|
+
*/
|
|
896
|
+
async assignIdentities(fetched, signal) {
|
|
897
|
+
if (!this.locations)
|
|
898
|
+
return fetched.map((copy) => copy.message);
|
|
899
|
+
const stored = new Map((await this.locations.findLocations(fetched.map((copy) => copy.tuple))).map((location) => [tupleKey(location), location]));
|
|
900
|
+
const nativeSource = this.nativeSource();
|
|
901
|
+
const candidates = new Set();
|
|
902
|
+
for (const copy of fetched) {
|
|
903
|
+
if (stored.has(tupleKey(copy.tuple)))
|
|
904
|
+
continue;
|
|
905
|
+
const id = validNativeId(nativeSource, copy.emailId) ?? copy.rfcMessageId;
|
|
906
|
+
if (id)
|
|
907
|
+
candidates.add(id);
|
|
908
|
+
}
|
|
909
|
+
const sources = await this.locations.findMessageSources([...candidates]);
|
|
910
|
+
const claimed = new Map();
|
|
911
|
+
const messages = [];
|
|
912
|
+
for (const copy of fetched) {
|
|
913
|
+
throwIfAborted(signal);
|
|
914
|
+
const existing = stored.get(tupleKey(copy.tuple));
|
|
915
|
+
const locationId = existing?.id ?? randomUUID();
|
|
916
|
+
const nativeId = validNativeId(nativeSource, copy.emailId);
|
|
917
|
+
let identity;
|
|
918
|
+
if (existing) {
|
|
919
|
+
if (nativeId &&
|
|
920
|
+
(existing.source === 'objectid' ||
|
|
921
|
+
existing.source === 'x-gm-msgid') &&
|
|
922
|
+
(existing.source !== nativeSource ||
|
|
923
|
+
existing.providerMessageId !== nativeId)) {
|
|
924
|
+
throw Object.assign(new Error('The IMAP native identity conflicts with its stored copy.'), { code: 'IMAP_IDENTITY_CONFLICT' });
|
|
925
|
+
}
|
|
926
|
+
// A verified location keeps its identity even if a native field is missing now.
|
|
927
|
+
identity = {
|
|
928
|
+
id: existing.providerMessageId,
|
|
929
|
+
source: existing.source ?? 'locator',
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
else if (nativeId && nativeSource) {
|
|
933
|
+
if (sources.has(nativeId) && sources.get(nativeId) !== nativeSource) {
|
|
934
|
+
throw Object.assign(new Error('The IMAP identity source conflicts with a stored message.'), { code: 'IMAP_IDENTITY_CONFLICT' });
|
|
935
|
+
}
|
|
936
|
+
if (!sources.has(nativeId) || sources.get(nativeId) === nativeSource)
|
|
937
|
+
identity = { id: nativeId, source: nativeSource };
|
|
938
|
+
}
|
|
939
|
+
else if (copy.rfcMessageId) {
|
|
940
|
+
const id = copy.rfcMessageId;
|
|
941
|
+
if (sources.has(id) &&
|
|
942
|
+
sources.get(id) !== undefined &&
|
|
943
|
+
sources.get(id) !== 'rfc-message-id') {
|
|
944
|
+
throw Object.assign(new Error('The IMAP identity source conflicts with a stored message.'), { code: 'IMAP_IDENTITY_CONFLICT' });
|
|
945
|
+
}
|
|
946
|
+
const claimer = claimed.get(id);
|
|
947
|
+
if (claimer
|
|
948
|
+
? await this.sameRawCopies(claimer, copy, signal)
|
|
949
|
+
: !sources.has(id) ||
|
|
950
|
+
(sources.get(id) === 'rfc-message-id' &&
|
|
951
|
+
(await this.matchesStoredCopy(id, copy, signal)))) {
|
|
952
|
+
identity = { id, source: 'rfc-message-id' };
|
|
953
|
+
if (!claimer)
|
|
954
|
+
claimed.set(id, copy);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
identity ??= {
|
|
958
|
+
id: encodeCopyLocator(copy.tuple, locationId),
|
|
959
|
+
source: 'locator',
|
|
960
|
+
};
|
|
961
|
+
this.copies.push({
|
|
962
|
+
...copy.tuple,
|
|
963
|
+
observed: copy.observed,
|
|
964
|
+
locationId,
|
|
965
|
+
providerMessageId: identity.id,
|
|
966
|
+
source: identity.source,
|
|
967
|
+
read: copy.message.read,
|
|
968
|
+
starred: copy.message.starred,
|
|
969
|
+
receivedAt: copy.message.receivedAt,
|
|
970
|
+
});
|
|
971
|
+
messages.push({ ...copy.message, providerMessageId: identity.id });
|
|
658
972
|
}
|
|
659
973
|
return messages;
|
|
660
974
|
}
|
|
975
|
+
nativeSource() {
|
|
976
|
+
const capabilities = this.imapClient?.capabilities;
|
|
977
|
+
if (capabilities?.has('OBJECTID'))
|
|
978
|
+
return 'objectid';
|
|
979
|
+
if (capabilities?.has('X-GM-EXT-1'))
|
|
980
|
+
return 'x-gm-msgid';
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
/** Compare a new copy with a verified copy already stored under this RFC Message-ID. */
|
|
984
|
+
async matchesStoredCopy(id, copy, signal) {
|
|
985
|
+
for (const location of await this.locations.listValidLocations(id)) {
|
|
986
|
+
if (location.source !== 'rfc-message-id')
|
|
987
|
+
return false;
|
|
988
|
+
const stored = await this.openCopy(location, {
|
|
989
|
+
size: true,
|
|
990
|
+
bodyStructure: true,
|
|
991
|
+
});
|
|
992
|
+
if (!stored)
|
|
993
|
+
continue;
|
|
994
|
+
return this.sameRawCopies({
|
|
995
|
+
message: copy.message,
|
|
996
|
+
tuple: location,
|
|
997
|
+
structure: stored.bodyStructure,
|
|
998
|
+
size: stored.size,
|
|
999
|
+
}, copy, signal);
|
|
1000
|
+
}
|
|
1001
|
+
// A candidate that is gone, pending or unreadable cannot prove anything.
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
async sameRawCopies(left, right, signal) {
|
|
1005
|
+
const first = await this.rawCopy(left, signal);
|
|
1006
|
+
const second = first && (await this.rawCopy(right, signal));
|
|
1007
|
+
return Boolean(first && second && first.equals(second));
|
|
1008
|
+
}
|
|
1009
|
+
/** Complete raw MIME of one copy within the per-message and per-step budgets. */
|
|
1010
|
+
async rawCopy(copy, signal) {
|
|
1011
|
+
const key = tupleKey(copy.tuple);
|
|
1012
|
+
if (this.rawCache.has(key))
|
|
1013
|
+
return this.rawCache.get(key);
|
|
1014
|
+
let raw;
|
|
1015
|
+
if (rawComparable(copy.structure, copy.size, MAX_IMAP_RAW_COMPARE_BYTES) &&
|
|
1016
|
+
copy.size + 1 <= this.rawBudget) {
|
|
1017
|
+
// The probe byte past the reported size is charged as well.
|
|
1018
|
+
this.rawBudget -= copy.size + 1;
|
|
1019
|
+
try {
|
|
1020
|
+
const client = await this.imap();
|
|
1021
|
+
const mailbox = await client.mailboxOpen(copy.tuple.folder, {
|
|
1022
|
+
readOnly: true,
|
|
1023
|
+
});
|
|
1024
|
+
raw =
|
|
1025
|
+
String(mailbox.uidValidity) === copy.tuple.uidValidity
|
|
1026
|
+
? await readRawSource(client, copy.tuple.uid, copy.size, signal)
|
|
1027
|
+
: undefined;
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
const classified = classifyError(error, 'IMAP_READ_SOURCE');
|
|
1031
|
+
if (classified.retryable || classified.category === 'authentication')
|
|
1032
|
+
throw error;
|
|
1033
|
+
raw = undefined;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
this.rawCache.set(key, raw);
|
|
1037
|
+
return raw;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Select a stored copy and confirm it is still the same message. Returns
|
|
1041
|
+
* undefined for a changed generation, a missing UID or another identity.
|
|
1042
|
+
*/
|
|
1043
|
+
async openCopy(location, query, readOnly = true) {
|
|
1044
|
+
try {
|
|
1045
|
+
const client = await this.imap();
|
|
1046
|
+
const mailbox = await client.mailboxOpen(location.folder, { readOnly });
|
|
1047
|
+
if (String(mailbox.uidValidity) !== location.uidValidity)
|
|
1048
|
+
return undefined;
|
|
1049
|
+
const message = await client.fetchOne(location.uid, { ...query, uid: true, envelope: true }, { uid: true });
|
|
1050
|
+
if (!message || message.uid !== location.uid)
|
|
1051
|
+
return undefined;
|
|
1052
|
+
const source = location.source ?? 'locator';
|
|
1053
|
+
if (source === 'objectid' || source === 'x-gm-msgid') {
|
|
1054
|
+
const currentSource = this.nativeSource();
|
|
1055
|
+
const nativeId = validNativeId(currentSource, message.emailId);
|
|
1056
|
+
// A UID cannot identify another message within the same UIDVALIDITY.
|
|
1057
|
+
// Missing native metadata preserves this previously verified tuple.
|
|
1058
|
+
return !nativeId ||
|
|
1059
|
+
(currentSource === source && nativeId === location.providerMessageId)
|
|
1060
|
+
? message
|
|
1061
|
+
: undefined;
|
|
1062
|
+
}
|
|
1063
|
+
if (source === 'rfc-message-id')
|
|
1064
|
+
return message.envelope?.messageId === location.providerMessageId
|
|
1065
|
+
? message
|
|
1066
|
+
: undefined;
|
|
1067
|
+
return source === 'locator' &&
|
|
1068
|
+
encodeCopyLocator(location, location.id) === location.providerMessageId
|
|
1069
|
+
? message
|
|
1070
|
+
: undefined;
|
|
1071
|
+
}
|
|
1072
|
+
catch (error) {
|
|
1073
|
+
const classified = classifyError(error, 'IMAP_OPEN_COPY');
|
|
1074
|
+
if (classified.retryable || classified.category === 'authentication')
|
|
1075
|
+
throw error;
|
|
1076
|
+
return undefined;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
copyChanged() {
|
|
1080
|
+
return failure('IMAP_MESSAGE_COPY_CHANGED', 'An IMAP copy of this message changed. Synchronize the mailbox and try again.', 'provider', false);
|
|
1081
|
+
}
|
|
1082
|
+
/** Change one flag on every verified copy, committing each confirmed copy on its own. */
|
|
1083
|
+
async updateCopyFlags(providerMessageId, flag, enabled, signal) {
|
|
1084
|
+
try {
|
|
1085
|
+
throwIfAborted(signal);
|
|
1086
|
+
const locations = await this.locations.listValidLocations(providerMessageId);
|
|
1087
|
+
if (locations.length === 0)
|
|
1088
|
+
return failure('IMAP_MESSAGE_NOT_FOUND', 'No verified IMAP copy of this message is available.', 'provider', false);
|
|
1089
|
+
const client = await this.imap();
|
|
1090
|
+
for (const location of locations) {
|
|
1091
|
+
throwIfAborted(signal);
|
|
1092
|
+
if (!(await this.openCopy(location, { uid: true }, false)))
|
|
1093
|
+
return this.copyChanged();
|
|
1094
|
+
const uid = String(location.uid);
|
|
1095
|
+
const method = enabled ? 'messageFlagsAdd' : 'messageFlagsRemove';
|
|
1096
|
+
if (!(await client[method](uid, [flag], { uid: true })))
|
|
1097
|
+
throw new Error('The IMAP server did not update the message flags.');
|
|
1098
|
+
const confirmed = await client.fetchOne(uid, { uid: true, flags: true }, { uid: true });
|
|
1099
|
+
if (!confirmed ||
|
|
1100
|
+
confirmed.uid !== location.uid ||
|
|
1101
|
+
!confirmed.flags ||
|
|
1102
|
+
confirmed.flags.has(flag) !== enabled)
|
|
1103
|
+
throw new Error('The IMAP server did not confirm the message flags.');
|
|
1104
|
+
await this.locations.commitFlags(location, flag === '\\Seen' ? { read: enabled } : { starred: enabled });
|
|
1105
|
+
}
|
|
1106
|
+
return { ok: true, value: undefined };
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
return { ok: false, error: classifyError(error, 'IMAP_UPDATE_FLAGS') };
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* One complete pass over every selectable folder. It reconciles stored
|
|
1114
|
+
* copies, and two matching passes in a row authorize orphan cleanup.
|
|
1115
|
+
*/
|
|
1116
|
+
async verifyFolders(mailboxes, folders, previous, signal) {
|
|
1117
|
+
const attempts = (previous?.attempts ?? 0) + 1;
|
|
1118
|
+
const snapshot = {};
|
|
1119
|
+
const present = new Map();
|
|
1120
|
+
const excluded = new Set(previous?.excluded ?? []);
|
|
1121
|
+
const uncertainGenerations = new Set();
|
|
1122
|
+
let stable = true;
|
|
1123
|
+
const known = await this.locations.listKnownLocations();
|
|
1124
|
+
for (const mailbox of mailboxes) {
|
|
1125
|
+
throwIfAborted(signal);
|
|
1126
|
+
const client = await this.imap();
|
|
1127
|
+
const selected = await client.mailboxOpen(mailbox.path, {
|
|
1128
|
+
readOnly: true,
|
|
1129
|
+
});
|
|
1130
|
+
const state = await this.selectedFolderState(selected);
|
|
1131
|
+
const cursor = folders[mailbox.path];
|
|
1132
|
+
if (state.uidValidity !== cursor.uidValidity)
|
|
1133
|
+
return 'generation-changed';
|
|
1134
|
+
// New mail arrived after this page: import it before verifying again.
|
|
1135
|
+
if (state.uidNext !== cursor.uidNext)
|
|
1136
|
+
stable = false;
|
|
1137
|
+
let uids;
|
|
1138
|
+
try {
|
|
1139
|
+
uids = await client.search({ all: true }, { uid: true });
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
if (!isResponseLimitError(error))
|
|
1143
|
+
throw error;
|
|
1144
|
+
// The UID list exceeds the protocol budget: keep every copy and skip cleanup.
|
|
1145
|
+
this.closeImap();
|
|
1146
|
+
this.verificationIncomplete = true;
|
|
1147
|
+
return { done: true, verify: { attempts, excluded: [...excluded] } };
|
|
1148
|
+
}
|
|
1149
|
+
if (!Array.isArray(uids))
|
|
1150
|
+
// Retry the step: an incomplete SEARCH proves nothing about missing copies.
|
|
1151
|
+
throw Object.assign(new Error(`Could not search ${mailbox.path} during verification.`), { code: 'IMAP_SEARCH_FAILED', serverResponseCode: 'UNAVAILABLE' });
|
|
1152
|
+
if (uids.some((uid) => !Number.isSafeInteger(uid) || uid < 1 || uid > 4294967295))
|
|
1153
|
+
throw new Error('Invalid UID in IMAP SEARCH.');
|
|
1154
|
+
present.set(mailbox.path, new Set(uids));
|
|
1155
|
+
snapshot[mailbox.path] = { ...state, uids: compressUids(uids) };
|
|
1156
|
+
if (Buffer.byteLength(JSON.stringify(snapshot)) >
|
|
1157
|
+
MAX_IMAP_VERIFY_STATE_BYTES) {
|
|
1158
|
+
this.verificationIncomplete = true;
|
|
1159
|
+
return { done: true, verify: { attempts, excluded: [...excluded] } };
|
|
1160
|
+
}
|
|
1161
|
+
const bound = new Set(known
|
|
1162
|
+
.filter((location) => location.folder === mailbox.path &&
|
|
1163
|
+
location.uidValidity === state.uidValidity &&
|
|
1164
|
+
location.status === 'valid')
|
|
1165
|
+
.map((location) => location.uid));
|
|
1166
|
+
const unbound = uids.filter((uid) => !bound.has(uid) &&
|
|
1167
|
+
!excluded.has(tupleKey({
|
|
1168
|
+
folder: mailbox.path,
|
|
1169
|
+
uidValidity: state.uidValidity,
|
|
1170
|
+
uid,
|
|
1171
|
+
})));
|
|
1172
|
+
if (unbound.length) {
|
|
1173
|
+
// Reconcile copies below the scan high-water mark, including old-date moves.
|
|
1174
|
+
const fetched = await this.fetchMessages(mailbox.path, state.uidValidity, unbound.slice(0, this.syncRun?.policy.batchSize ?? 100), signal);
|
|
1175
|
+
const sources = await this.locations.findMessageSources(fetched.flatMap((copy) => {
|
|
1176
|
+
const id = validNativeId(this.nativeSource(), copy.emailId) ??
|
|
1177
|
+
copy.rfcMessageId;
|
|
1178
|
+
return id ? [id] : [];
|
|
1179
|
+
}));
|
|
1180
|
+
for (const copy of fetched) {
|
|
1181
|
+
const id = validNativeId(this.nativeSource(), copy.emailId) ??
|
|
1182
|
+
copy.rfcMessageId;
|
|
1183
|
+
const beforeImportDate = this.syncRun?.policy.receivedAfter &&
|
|
1184
|
+
copy.message.receivedAt &&
|
|
1185
|
+
Date.parse(copy.message.receivedAt) <
|
|
1186
|
+
Date.parse(this.syncRun.policy.receivedAfter);
|
|
1187
|
+
if (beforeImportDate && !copy.observed && (!id || !sources.has(id)))
|
|
1188
|
+
excluded.add(tupleKey(copy.tuple));
|
|
1189
|
+
else
|
|
1190
|
+
this.verificationCopies.push(copy);
|
|
1191
|
+
}
|
|
1192
|
+
stable = false;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const after = this.selectableMailboxes(await this.listMailboxes());
|
|
1196
|
+
if (after.length !== mailboxes.length ||
|
|
1197
|
+
after.some((box) => !snapshot[box.path]))
|
|
1198
|
+
stable = false;
|
|
1199
|
+
for (const mailbox of after) {
|
|
1200
|
+
const selected = await (await this.imap()).mailboxOpen(mailbox.path, { readOnly: true });
|
|
1201
|
+
const state = await this.selectedFolderState(selected);
|
|
1202
|
+
const rechecked = await (await this.imap()).search({ all: true }, { uid: true });
|
|
1203
|
+
if (!Array.isArray(rechecked))
|
|
1204
|
+
throw new Error('The final IMAP SEARCH was incomplete.');
|
|
1205
|
+
if (compressUids(rechecked) !== snapshot[mailbox.path]?.uids)
|
|
1206
|
+
stable = false;
|
|
1207
|
+
if (state.uidValidity !== snapshot[mailbox.path]?.uidValidity ||
|
|
1208
|
+
state.uidNext !== snapshot[mailbox.path]?.uidNext)
|
|
1209
|
+
stable = false;
|
|
1210
|
+
if (state.uidValidity !== snapshot[mailbox.path]?.uidValidity)
|
|
1211
|
+
uncertainGenerations.add(mailbox.path);
|
|
1212
|
+
}
|
|
1213
|
+
const finalRange = this.selectableMailboxes(await this.listMailboxes());
|
|
1214
|
+
const finalPaths = new Set(finalRange.map((mailbox) => mailbox.path));
|
|
1215
|
+
if (finalPaths.size !== mailboxes.length ||
|
|
1216
|
+
mailboxes.some((mailbox) => !finalPaths.has(mailbox.path)))
|
|
1217
|
+
stable = false;
|
|
1218
|
+
for (const mailbox of finalRange) {
|
|
1219
|
+
const generation = mailbox.status?.uidValidity;
|
|
1220
|
+
const uidNext = mailbox.status?.uidNext;
|
|
1221
|
+
if (generation !== undefined &&
|
|
1222
|
+
String(generation) !== snapshot[mailbox.path]?.uidValidity) {
|
|
1223
|
+
uncertainGenerations.add(mailbox.path);
|
|
1224
|
+
stable = false;
|
|
1225
|
+
}
|
|
1226
|
+
if (uidNext !== undefined && uidNext !== snapshot[mailbox.path]?.uidNext)
|
|
1227
|
+
stable = false;
|
|
1228
|
+
}
|
|
1229
|
+
for (const location of known) {
|
|
1230
|
+
if (this.verificationCopies.some((copy) => tupleKey(copy.tuple) === tupleKey(location)))
|
|
1231
|
+
continue;
|
|
1232
|
+
const uids = present.get(location.folder);
|
|
1233
|
+
const generation = snapshot[location.folder]?.uidValidity;
|
|
1234
|
+
const replaced = generation !== location.uidValidity &&
|
|
1235
|
+
(location.source === 'objectid' || location.source === 'x-gm-msgid') &&
|
|
1236
|
+
known.some((copy) => copy.messageId === location.messageId &&
|
|
1237
|
+
copy.source === location.source &&
|
|
1238
|
+
copy.providerMessageId === location.providerMessageId &&
|
|
1239
|
+
copy.status === 'valid' &&
|
|
1240
|
+
snapshot[copy.folder]?.uidValidity === copy.uidValidity &&
|
|
1241
|
+
present.get(copy.folder)?.has(copy.uid));
|
|
1242
|
+
if (stable &&
|
|
1243
|
+
location.status === 'valid' &&
|
|
1244
|
+
location.source &&
|
|
1245
|
+
generation === location.uidValidity &&
|
|
1246
|
+
uids?.has(location.uid)) {
|
|
1247
|
+
const observed = await this.openCopy(location, { flags: true });
|
|
1248
|
+
if (!observed?.flags)
|
|
1249
|
+
throw new Error('The IMAP copy identity or flags changed during verification.');
|
|
1250
|
+
this.copies.push({
|
|
1251
|
+
flagsOnly: true,
|
|
1252
|
+
locationId: location.id,
|
|
1253
|
+
observed: location,
|
|
1254
|
+
folder: location.folder,
|
|
1255
|
+
uidValidity: location.uidValidity,
|
|
1256
|
+
uid: location.uid,
|
|
1257
|
+
providerMessageId: location.providerMessageId,
|
|
1258
|
+
source: location.source,
|
|
1259
|
+
read: observed.flags.has('\\Seen'),
|
|
1260
|
+
starred: observed.flags.has('\\Flagged'),
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
const next = !finalPaths.has(location.folder)
|
|
1264
|
+
? { status: 'pending', reason: 'folder-unavailable' }
|
|
1265
|
+
: uncertainGenerations.has(location.folder)
|
|
1266
|
+
? { status: 'pending', reason: 'generation-changed' }
|
|
1267
|
+
: replaced && stable
|
|
1268
|
+
? { status: 'invalid', reason: 'generation-replaced' }
|
|
1269
|
+
: !uids
|
|
1270
|
+
? { status: 'pending', reason: 'folder-unavailable' }
|
|
1271
|
+
: generation !== location.uidValidity
|
|
1272
|
+
? { status: 'pending', reason: 'generation-changed' }
|
|
1273
|
+
: uids.has(location.uid)
|
|
1274
|
+
? { status: 'valid' }
|
|
1275
|
+
: { status: 'invalid', reason: 'uid-missing' };
|
|
1276
|
+
if (next.status !== 'valid' || next.status !== location.status)
|
|
1277
|
+
this.changes.push({ id: location.id, observed: location, ...next });
|
|
1278
|
+
}
|
|
1279
|
+
if (Buffer.byteLength(JSON.stringify({ snapshot, excluded: [...excluded] })) >
|
|
1280
|
+
MAX_IMAP_VERIFY_STATE_BYTES) {
|
|
1281
|
+
this.verificationIncomplete = true;
|
|
1282
|
+
return { done: true, verify: { attempts } };
|
|
1283
|
+
}
|
|
1284
|
+
if (!stable) {
|
|
1285
|
+
if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS) {
|
|
1286
|
+
this.verificationIncomplete = true;
|
|
1287
|
+
return { done: true, verify: { attempts, excluded: [...excluded] } };
|
|
1288
|
+
}
|
|
1289
|
+
return { done: false, verify: { attempts, excluded: [...excluded] } };
|
|
1290
|
+
}
|
|
1291
|
+
if (previous?.folders && sameSnapshot(previous.folders, snapshot)) {
|
|
1292
|
+
this.cleanup = true;
|
|
1293
|
+
return { done: true };
|
|
1294
|
+
}
|
|
1295
|
+
if (attempts >= MAX_IMAP_VERIFY_ATTEMPTS) {
|
|
1296
|
+
this.verificationIncomplete = true;
|
|
1297
|
+
return { done: true, verify: { attempts, excluded: [...excluded] } };
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
done: false,
|
|
1301
|
+
verify: { attempts, folders: snapshot, excluded: [...excluded] },
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
661
1304
|
async normalizeMessage(folder, uidValidity, message, signal, maxBodyBytes = MAX_IMAP_BODY_BYTES) {
|
|
662
|
-
const header = message.headers
|
|
1305
|
+
const header = message.headers;
|
|
1306
|
+
if (!header ||
|
|
1307
|
+
header.byteLength > MAX_HEADER_BYTES ||
|
|
1308
|
+
!header.subarray(-4).equals(Buffer.from('\r\n\r\n'))) {
|
|
1309
|
+
throw Object.assign(new Error('The complete IMAP header could not be read within the header budget.'), { code: 'IMAP_HEADER_INCOMPLETE' });
|
|
1310
|
+
}
|
|
663
1311
|
const parsed = header
|
|
664
1312
|
? await simpleParser(Buffer.concat([header, Buffer.from('\r\n\r\n')]), {
|
|
665
1313
|
skipHtmlToText: true,
|
|
@@ -698,7 +1346,8 @@ export class ImapSmtpAdapter {
|
|
|
698
1346
|
providerMessageId,
|
|
699
1347
|
...body,
|
|
700
1348
|
size: message.size,
|
|
701
|
-
|
|
1349
|
+
// Preserve the header value; mailparser can wrap malformed bare IDs in brackets.
|
|
1350
|
+
internetMessageId: /^message-id:[\t ]*([^\r\n]*(?:\r\n[\t ][^\r\n]*)*)\r\n/im.exec(header.toString('utf8'))?.[1],
|
|
702
1351
|
providerFolderIds: [folder],
|
|
703
1352
|
from,
|
|
704
1353
|
to,
|
|
@@ -718,4 +1367,46 @@ export class ImapSmtpAdapter {
|
|
|
718
1367
|
};
|
|
719
1368
|
}
|
|
720
1369
|
}
|
|
1370
|
+
function tupleKey(tuple) {
|
|
1371
|
+
return JSON.stringify([tuple.folder, tuple.uidValidity, tuple.uid]);
|
|
1372
|
+
}
|
|
1373
|
+
const MAX_UINT64 = 2n ** 64n - 1n;
|
|
1374
|
+
/** The server's own object ID, never ImapFlow's synthesized message.id. */
|
|
1375
|
+
function validNativeId(source, value) {
|
|
1376
|
+
if (!value)
|
|
1377
|
+
return undefined;
|
|
1378
|
+
if (source === 'objectid')
|
|
1379
|
+
return /^[A-Za-z0-9_-]{1,255}$/.test(value) ? value : undefined;
|
|
1380
|
+
if (source === 'x-gm-msgid')
|
|
1381
|
+
// Keep the decimal string: a JavaScript number would lose uint64 precision.
|
|
1382
|
+
return /^(0|[1-9]\d{0,19})$/.test(value) && BigInt(value) <= MAX_UINT64
|
|
1383
|
+
? value
|
|
1384
|
+
: undefined;
|
|
1385
|
+
return undefined;
|
|
1386
|
+
}
|
|
1387
|
+
function validRfcMessageId(value) {
|
|
1388
|
+
return value &&
|
|
1389
|
+
value.length <= MAX_IMAP_PROVIDER_ID_LENGTH &&
|
|
1390
|
+
/^<[\x21-\x3b\x3d\x3f-\x7e]+@[\x21-\x3b\x3d\x3f-\x7e]+>$/.test(value)
|
|
1391
|
+
? value
|
|
1392
|
+
: undefined;
|
|
1393
|
+
}
|
|
1394
|
+
function sameSnapshot(left, right) {
|
|
1395
|
+
const paths = Object.keys(right);
|
|
1396
|
+
return (Object.keys(left).length === paths.length &&
|
|
1397
|
+
paths.every((path) => left[path]?.uidValidity === right[path].uidValidity &&
|
|
1398
|
+
left[path].uidNext === right[path].uidNext &&
|
|
1399
|
+
left[path].uids === right[path].uids));
|
|
1400
|
+
}
|
|
1401
|
+
function isResponseLimitError(error) {
|
|
1402
|
+
return ['LiteralTooLarge', 'ResponseTooLarge', 'LineTooLarge'].includes(error?.code ?? '');
|
|
1403
|
+
}
|
|
1404
|
+
/** ImapFlow 2.2.10's command primitive keeps the verb explicitly scoped to a UID. */
|
|
1405
|
+
async function expungeUid(client, uid) {
|
|
1406
|
+
const connection = client;
|
|
1407
|
+
const response = await connection.exec('UID EXPUNGE', [
|
|
1408
|
+
{ type: 'SEQUENCE', value: String(uid) },
|
|
1409
|
+
]);
|
|
1410
|
+
response.next();
|
|
1411
|
+
}
|
|
721
1412
|
//# sourceMappingURL=adapter.js.map
|