@wirecat/cli-core 0.471.0 → 0.473.0
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 +48 -0
- package/dist/cli/execution.d.ts +5 -0
- package/dist/cli/execution.js +5 -0
- package/dist/cli/help/first-screen.js +5 -0
- package/dist/cli/messenger/conversation-answers.d.ts +1 -0
- package/dist/cli/messenger/hearing-command.js +1 -1
- package/dist/cli/messenger/message-answers.d.ts +1 -0
- package/dist/cli/messenger/message-items.js +2 -0
- package/dist/cli/messenger/message-writes.d.ts +2 -0
- package/dist/cli/program.js +5 -3
- package/dist/entities/conversation-results.d.ts +2 -0
- package/dist/entities/evidence.d.ts +8 -0
- package/dist/entities/index.d.ts +19 -0
- package/dist/entities/knowledge-lists.d.ts +1 -0
- package/dist/entities/knowledge-lists.js +1 -0
- package/dist/entities/listener.d.ts +4 -0
- package/dist/entities/message-context.d.ts +3 -0
- package/dist/entities/message-write.d.ts +4 -0
- package/dist/entities/message.d.ts +1 -0
- package/dist/entities/message.js +1 -0
- package/dist/entities/store-records.d.ts +1 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/mail/bulk.d.ts +7 -0
- package/dist/mail/bulk.js +3 -0
- package/dist/mail/facts.d.ts +9 -0
- package/dist/mail/facts.js +22 -0
- package/dist/mail/folders.d.ts +10 -0
- package/dist/mail/folders.js +13 -0
- package/dist/mail/gone.d.ts +20 -0
- package/dist/mail/gone.js +53 -0
- package/dist/mail/headers.d.ts +43 -0
- package/dist/mail/headers.js +88 -0
- package/dist/mail/index.d.ts +17 -0
- package/dist/mail/index.js +16 -0
- package/dist/mail/parse.d.ts +24 -0
- package/dist/mail/parse.js +79 -0
- package/dist/mail/presets.d.ts +30 -0
- package/dist/mail/presets.js +95 -0
- package/dist/mail/probe.d.ts +49 -0
- package/dist/mail/probe.js +81 -0
- package/dist/mail/resume.d.ts +61 -0
- package/dist/mail/resume.js +61 -0
- package/dist/mail/rule-input.d.ts +14 -0
- package/dist/mail/rule-input.js +32 -0
- package/dist/mail/rule-tags.d.ts +40 -0
- package/dist/mail/rule-tags.js +87 -0
- package/dist/mail/rules.d.ts +83 -0
- package/dist/mail/rules.js +108 -0
- package/dist/mail/score.d.ts +67 -0
- package/dist/mail/score.js +174 -0
- package/dist/mail/sender-scores.d.ts +30 -0
- package/dist/mail/sender-scores.js +96 -0
- package/dist/mail/source.d.ts +74 -0
- package/dist/mail/source.js +1 -0
- package/dist/mail/stored.d.ts +7 -0
- package/dist/mail/stored.js +36 -0
- package/dist/mail/sync.d.ts +106 -0
- package/dist/mail/sync.js +748 -0
- package/dist/mail/testing/contract.d.ts +20 -0
- package/dist/mail/testing/contract.js +106 -0
- package/dist/mail/testing/fake.d.ts +72 -0
- package/dist/mail/testing/fake.js +156 -0
- package/dist/mail/testing/index.d.ts +2 -0
- package/dist/mail/testing/index.js +2 -0
- package/dist/runs/error-detail.d.ts +2 -0
- package/dist/runs/error-detail.js +4 -0
- package/dist/speech/hearing.d.ts +2 -0
- package/dist/speech/hearing.js +10 -0
- package/dist/store/knowledge-query.js +3 -1
- package/dist/store/people.d.ts +2 -0
- package/dist/store/people.js +12 -0
- package/dist/store/sqlite/knowledge.d.ts +3 -1
- package/dist/store/sqlite/knowledge.js +7 -2
- package/dist/store/sqlite/task-keys.d.ts +1 -0
- package/dist/store/sqlite/task-keys.js +1 -0
- package/package.json +12 -1
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { extractText, importEngine } from "../attachments/extract.js";
|
|
3
|
+
import { BULK, bulkBox } from "./bulk.js";
|
|
4
|
+
import { factsOf } from "./facts.js";
|
|
5
|
+
import { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
|
|
6
|
+
import { dropMissing, tooManyGone } from "./gone.js";
|
|
7
|
+
import { mailHeadersOf } from "./headers.js";
|
|
8
|
+
import { MAX_BODY_CHARS, parseMail } from "./parse.js";
|
|
9
|
+
import { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
|
|
10
|
+
import { ruleTagger, settleRuleTags } from "./rule-tags.js";
|
|
11
|
+
import { decide, ENVELOPE, envelopeBox, envelopeBoxOf, tagsOf } from "./rules.js";
|
|
12
|
+
const DAY = 86_400_000;
|
|
13
|
+
/** IMAP's SINCE counts whole days in the server's zone, so the window's first days are not trusted. */
|
|
14
|
+
const EDGE = 2 * DAY;
|
|
15
|
+
export const MAX_ATTACHMENT_CHARS = 200_000;
|
|
16
|
+
/** One chunk of raw mail is held in memory at a time. */
|
|
17
|
+
const FETCH_CHUNK = 50;
|
|
18
|
+
/** How often a long run saves where it is, so a kill costs at most this much listing again. */
|
|
19
|
+
const SAVE_EVERY_MS = 30_000;
|
|
20
|
+
/** About a minute of listing; the 30-day window of the timer stays below it. */
|
|
21
|
+
const RESUME_FROM = 5_000;
|
|
22
|
+
const addressOf = ({ email, name }) => ({ address: email, name });
|
|
23
|
+
/** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
|
|
24
|
+
const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
|
|
25
|
+
const files = [];
|
|
26
|
+
for (const [position, part] of mail.attachments.entries()) {
|
|
27
|
+
const extraction = part.bytes === null
|
|
28
|
+
? { status: "unavailable" }
|
|
29
|
+
: await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
|
|
30
|
+
log.push({ message: messageId, part: part.id, status: extraction.status });
|
|
31
|
+
const extracted = extraction.status === "extracted" ? extraction : undefined;
|
|
32
|
+
files.push({
|
|
33
|
+
position,
|
|
34
|
+
kind: "file",
|
|
35
|
+
mime: part.mime,
|
|
36
|
+
name: part.name,
|
|
37
|
+
title: null,
|
|
38
|
+
url: null,
|
|
39
|
+
size: part.bytes === null ? null : part.bytes.byteLength,
|
|
40
|
+
width: null,
|
|
41
|
+
height: null,
|
|
42
|
+
duration: null,
|
|
43
|
+
providerRef: { message: messageId, part: part.id },
|
|
44
|
+
localPath: null,
|
|
45
|
+
text: extracted ? extracted.text.slice(0, maxAttachmentChars) : null,
|
|
46
|
+
normalizedText: null,
|
|
47
|
+
extraction: extracted ? "text" : "failed",
|
|
48
|
+
extractor: extracted ? extracted.extractor : null,
|
|
49
|
+
extractionError: extracted ? null : extraction.status,
|
|
50
|
+
contentSha256: null,
|
|
51
|
+
extractedAt: at,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return files;
|
|
55
|
+
};
|
|
56
|
+
const LIST_SLICE = 200;
|
|
57
|
+
const FETCH_SLICE = 50;
|
|
58
|
+
const keyOf = (folder, uid) => `${folder}\0${uid}`;
|
|
59
|
+
export const syncMail = async (source, store, options) => {
|
|
60
|
+
const { budget, index } = options;
|
|
61
|
+
if (budget === undefined || options.retryAttachments || options.account.mode === "mbox")
|
|
62
|
+
return syncRound(source, store, options);
|
|
63
|
+
const plan = {
|
|
64
|
+
budget,
|
|
65
|
+
tried: new Set(),
|
|
66
|
+
failed: new Set(),
|
|
67
|
+
searches: new Map(),
|
|
68
|
+
modes: new Map(),
|
|
69
|
+
unreadable: new Map(),
|
|
70
|
+
unlisted: new Set(),
|
|
71
|
+
stopped: null,
|
|
72
|
+
remaining: 0,
|
|
73
|
+
asked: 0,
|
|
74
|
+
};
|
|
75
|
+
const rounds = [];
|
|
76
|
+
const goOn = async () => {
|
|
77
|
+
for (;;) {
|
|
78
|
+
plan.asked = 0;
|
|
79
|
+
plan.remaining = 0;
|
|
80
|
+
try {
|
|
81
|
+
rounds.push(await syncRound(source, store, { ...options, index: undefined, max: options.max ?? Number.POSITIVE_INFINITY }, plan));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// A request cut at the deadline, or failed after the budget was spent, is the run's end, not its failure: what
|
|
85
|
+
// earlier rounds saved is kept and the next run goes on. While the budget has room the error is real.
|
|
86
|
+
const transient = error instanceof CliError && (error.code === "timeout" || error.code === "network_error");
|
|
87
|
+
const stop = transient && rounds.length > 0 ? budget.take() : null;
|
|
88
|
+
if (stop === null)
|
|
89
|
+
throw error;
|
|
90
|
+
plan.stopped = stop;
|
|
91
|
+
plan.remaining = Math.max(plan.remaining, 1);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (plan.stopped !== null || plan.remaining === 0)
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
await goOn();
|
|
99
|
+
const remaining = plan.remaining;
|
|
100
|
+
// A message the server failed on once is often fine a minute later; only one more try, then the next run.
|
|
101
|
+
if (plan.stopped === null && plan.failed.size > 0) {
|
|
102
|
+
plan.only = new Set(plan.failed);
|
|
103
|
+
plan.failed.clear();
|
|
104
|
+
plan.tried.clear();
|
|
105
|
+
await goOn();
|
|
106
|
+
}
|
|
107
|
+
const last = rounds.at(-1);
|
|
108
|
+
const sum = (pick) => rounds.reduce((total, round) => total + pick(round), 0);
|
|
109
|
+
const unreadable = [
|
|
110
|
+
...[...plan.unreadable].map(([message, reason]) => ({ message, reason })),
|
|
111
|
+
...[...plan.unlisted].map((key) => ({
|
|
112
|
+
message: `uid:${key.replace("\0", ":")}`,
|
|
113
|
+
reason: "the server did not return it",
|
|
114
|
+
})),
|
|
115
|
+
];
|
|
116
|
+
const complete = last.complete && plan.stopped === null && unreadable.length === 0 && remaining === 0;
|
|
117
|
+
const account = { provider: "email", account: options.account.address.toLowerCase() };
|
|
118
|
+
const indexed = plan.stopped === null ? await index?.(store, account) : undefined;
|
|
119
|
+
const deletionsSkipped = rounds.map((round) => round.deletionsSkipped).findLast((text) => text !== undefined);
|
|
120
|
+
const { unreadable: _unreadable, ...rest } = last;
|
|
121
|
+
return {
|
|
122
|
+
...rest,
|
|
123
|
+
listed: sum((round) => round.listed),
|
|
124
|
+
saved: sum((round) => round.saved),
|
|
125
|
+
alreadyStored: sum((round) => round.alreadyStored),
|
|
126
|
+
skipped: sum((round) => round.skipped),
|
|
127
|
+
envelopes: sum((round) => round.envelopes),
|
|
128
|
+
deleted: sum((round) => round.deleted),
|
|
129
|
+
complete,
|
|
130
|
+
state: complete ? "complete" : "partial",
|
|
131
|
+
remaining,
|
|
132
|
+
requests: budget.used,
|
|
133
|
+
...(plan.stopped === null ? {} : { stopped: plan.stopped }),
|
|
134
|
+
...(indexed === undefined ? {} : { indexed }),
|
|
135
|
+
...(unreadable.length ? { unreadable } : {}),
|
|
136
|
+
...(deletionsSkipped === undefined ? {} : { deletionsSkipped }),
|
|
137
|
+
...(rounds.some((round) => round.pass === "resumed") ? { pass: "resumed" } : {}),
|
|
138
|
+
...(rounds.some((round) => round.attachments)
|
|
139
|
+
? { attachments: rounds.flatMap((round) => round.attachments ?? []) }
|
|
140
|
+
: {}),
|
|
141
|
+
...(rounds.some((round) => round.tagsReused)
|
|
142
|
+
? { tagsReused: rounds.flatMap((round) => round.tagsReused ?? []) }
|
|
143
|
+
: {}),
|
|
144
|
+
...(rounds.some((round) => round.tagsSkipped)
|
|
145
|
+
? { tagsSkipped: rounds.flatMap((round) => round.tagsSkipped ?? []) }
|
|
146
|
+
: {}),
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
const syncRound = async (source, store, { account, since, max = 200, maxBodyChars = MAX_BODY_CHARS, maxAttachmentChars = MAX_ATTACHMENT_CHARS, now = Date.now, ages, loadEngine = importEngine, retryAttachments = false, rescan = false, resumeFrom = RESUME_FROM, index, signal, }, plan) => {
|
|
150
|
+
if (retryAttachments)
|
|
151
|
+
return retryUnread({
|
|
152
|
+
store,
|
|
153
|
+
source,
|
|
154
|
+
account,
|
|
155
|
+
since,
|
|
156
|
+
max,
|
|
157
|
+
maxBodyChars,
|
|
158
|
+
maxAttachmentChars,
|
|
159
|
+
now,
|
|
160
|
+
loadEngine,
|
|
161
|
+
index,
|
|
162
|
+
});
|
|
163
|
+
const startedAt = now();
|
|
164
|
+
const key = { provider: "email", account: account.address.toLowerCase() };
|
|
165
|
+
const accountId = await store.accounts.saveAccount(key, { name: account.address });
|
|
166
|
+
const folders = await mailFolders(account, source);
|
|
167
|
+
if (folders.length < 1 || folders.length > 50)
|
|
168
|
+
throw new Error("select 1–50 mail folders");
|
|
169
|
+
const rules = account.rules ?? [];
|
|
170
|
+
// An archive file is a snapshot of the same account: it adds mail, and changes nothing IMAP keeps current.
|
|
171
|
+
const snapshot = account.mode === "mbox";
|
|
172
|
+
const ruleSet = rulesKey(rules);
|
|
173
|
+
const collected = [];
|
|
174
|
+
const searches = new Map();
|
|
175
|
+
/** The earlier point of each folder whose UIDs and rules still hold; its done mail stays done. */
|
|
176
|
+
const earlier = new Map();
|
|
177
|
+
/** Every folder's stored point, valid or not: the widest window outlives what invalidates the rest. */
|
|
178
|
+
const storedPoints = new Map();
|
|
179
|
+
const resumed = new Set();
|
|
180
|
+
let mboxPoint;
|
|
181
|
+
let mboxStamp;
|
|
182
|
+
// A rerun lists only what the last left; --rescan, a rule change or a new UIDVALIDITY lists everything again.
|
|
183
|
+
const fresh = rescan;
|
|
184
|
+
if (snapshot && source.stamp !== undefined) {
|
|
185
|
+
mboxStamp = await source.stamp();
|
|
186
|
+
const point = fresh ? undefined : parsePoint((await store.sync.syncState(key, MBOX_RESUME))?.value);
|
|
187
|
+
if (point?.complete && point.size === mboxStamp.size && point.mtimeMs === mboxStamp.mtimeMs)
|
|
188
|
+
mboxPoint = point;
|
|
189
|
+
}
|
|
190
|
+
let capped = false;
|
|
191
|
+
for (const folder of folders) {
|
|
192
|
+
if (mboxPoint !== undefined)
|
|
193
|
+
break;
|
|
194
|
+
let searched;
|
|
195
|
+
if (source.search !== undefined && !snapshot) {
|
|
196
|
+
searched = plan?.searches.get(folder);
|
|
197
|
+
if (searched === undefined) {
|
|
198
|
+
const stop = plan?.budget.take();
|
|
199
|
+
if (plan && stop) {
|
|
200
|
+
plan.stopped = stop;
|
|
201
|
+
capped = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
searched = await source.search(since, folder);
|
|
205
|
+
plan?.searches.set(folder, searched);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
let wanted = searched;
|
|
209
|
+
if (searched !== undefined) {
|
|
210
|
+
searches.set(folder, searched);
|
|
211
|
+
const kept = parsePoint((await store.sync.syncState(key, resumeKey(folder)))?.value);
|
|
212
|
+
if (kept !== undefined)
|
|
213
|
+
storedPoints.set(folder, kept);
|
|
214
|
+
const point = fresh ? undefined : kept;
|
|
215
|
+
if (point !== undefined && point.validity === searched.validity && point.rules === ruleSet) {
|
|
216
|
+
earlier.set(folder, point);
|
|
217
|
+
const mode = plan?.modes.get(folder);
|
|
218
|
+
const usable = Date.parse(point.full.since) <= since.getTime() && startedAt - Date.parse(point.full.at) < FULL_PASS_MS;
|
|
219
|
+
// A budgeted run goes on with an unfinished pass however small its window is: listing it whole again would
|
|
220
|
+
// ask for the same newest messages every run.
|
|
221
|
+
const unfinished = plan !== undefined && searched.uids.some((uid) => !within(point.done, Number(uid)));
|
|
222
|
+
if (mode === "resumed" ||
|
|
223
|
+
(mode === undefined && usable && (searched.uids.length >= resumeFrom || unfinished))) {
|
|
224
|
+
resumed.add(folder);
|
|
225
|
+
wanted = { ...searched, uids: searched.uids.filter((uid) => !within(point.done, Number(uid))) };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (plan !== undefined) {
|
|
229
|
+
plan.modes.set(folder, resumed.has(folder) ? "resumed" : "full");
|
|
230
|
+
const open = wanted.uids.filter((uid) => plan.only
|
|
231
|
+
? plan.only.has(keyOf(folder, uid)) && !plan.tried.has(keyOf(folder, uid))
|
|
232
|
+
: !plan.tried.has(keyOf(folder, uid)));
|
|
233
|
+
const slice = [...open].sort((a, b) => Number(b) - Number(a)).slice(0, source.listBatch ?? LIST_SLICE);
|
|
234
|
+
plan.remaining += open.length - slice.length;
|
|
235
|
+
wanted = { ...searched, uids: slice };
|
|
236
|
+
if (open.length > slice.length)
|
|
237
|
+
capped = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (plan !== undefined && wanted !== undefined && wanted.uids.length === 0)
|
|
241
|
+
continue;
|
|
242
|
+
if (plan !== undefined) {
|
|
243
|
+
const stop = plan.budget.take();
|
|
244
|
+
if (stop) {
|
|
245
|
+
plan.stopped = stop;
|
|
246
|
+
capped = true;
|
|
247
|
+
plan.remaining += (wanted?.uids ?? []).length;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
for (const uid of wanted?.uids ?? [])
|
|
251
|
+
plan.tried.add(keyOf(folder, uid));
|
|
252
|
+
plan.asked += (wanted?.uids ?? []).length;
|
|
253
|
+
}
|
|
254
|
+
const found = await source.list(since, folder, {
|
|
255
|
+
headers: rules.length > 0,
|
|
256
|
+
...(wanted === undefined ? {} : { searched: wanted }),
|
|
257
|
+
});
|
|
258
|
+
if (plan !== undefined && wanted !== undefined) {
|
|
259
|
+
const got = new Set(found.map(({ uid }) => uid));
|
|
260
|
+
for (const uid of wanted.uids) {
|
|
261
|
+
const key = keyOf(folder, uid);
|
|
262
|
+
if (got.has(uid))
|
|
263
|
+
plan.unlisted.delete(key);
|
|
264
|
+
else {
|
|
265
|
+
plan.failed.add(key);
|
|
266
|
+
plan.unlisted.add(key);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Spreading a whole mailbox into push() overflows the stack past ~120,000 emails.
|
|
271
|
+
for (const item of found)
|
|
272
|
+
collected.push(item);
|
|
273
|
+
}
|
|
274
|
+
const listed = [...new Map(collected.map((item) => [item.messageId, item])).values()].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt));
|
|
275
|
+
const present = new Set(listed.map(({ messageId }) => messageId));
|
|
276
|
+
const factsFor = factsOf(listed, rules, ages);
|
|
277
|
+
// A rule's tag goes on mail this run stores or records; mail stored earlier gets it from memo mail rules apply.
|
|
278
|
+
const tagger = ruleTagger(store, key);
|
|
279
|
+
const tagStored = async (item) => {
|
|
280
|
+
const tags = tagsOf(rules, factsFor(item));
|
|
281
|
+
if (tags.length)
|
|
282
|
+
await tagger.tag({ type: "message", chatId: item.threadId, messageId: item.messageId }, tags);
|
|
283
|
+
};
|
|
284
|
+
const foundFolders = new Map();
|
|
285
|
+
for (const item of collected) {
|
|
286
|
+
const current = foundFolders.get(item.messageId) ?? new Set();
|
|
287
|
+
current.add(item.folder ?? folders[0]);
|
|
288
|
+
foundFolders.set(item.messageId, current);
|
|
289
|
+
}
|
|
290
|
+
const mailbox = (folder) => ({ externalId: folder, name: folder, kind: null });
|
|
291
|
+
const label = (name) => ({ externalId: `label:${name}`, name, kind: "label" });
|
|
292
|
+
const labelsOf = new Map(collected.flatMap((item) => (item.labels === undefined ? [] : [[item.messageId, item]])));
|
|
293
|
+
const allMailOnly = account.mode !== "imap" && folders.length === 1 && (account.folders === undefined || folders[0] === GMAIL_ALL_MAIL);
|
|
294
|
+
// Deletions are proved against the mailboxes as stored before this scan moves anything.
|
|
295
|
+
const scope = JSON.stringify({ mode: account.mode ?? "gmail", folders: [...folders].sort() });
|
|
296
|
+
const previousScope = snapshot ? undefined : await store.sync.syncState(key, "mail_folder_scope");
|
|
297
|
+
const windowStart = new Date(since.getTime() + EDGE);
|
|
298
|
+
let deleted = 0;
|
|
299
|
+
let skipped;
|
|
300
|
+
if (snapshot)
|
|
301
|
+
skipped = "an mbox file is a snapshot; mail missing from it is not proven deleted";
|
|
302
|
+
else if (plan !== undefined && (capped || plan.tried.size > plan.asked || plan.only !== undefined))
|
|
303
|
+
skipped = "this run read the window in parts; a run that lists all of it at once checks deletions";
|
|
304
|
+
else if (resumed.size > 0)
|
|
305
|
+
skipped =
|
|
306
|
+
"this run listed only mail not read before; a full listing, at least daily or with --rescan, checks deletions";
|
|
307
|
+
else if (previousScope !== undefined && previousScope.value !== scope)
|
|
308
|
+
skipped = "folder scope changed; missing mail is not proven deleted";
|
|
309
|
+
else {
|
|
310
|
+
// A label is not where mail lives, so only folders prove that a missing mail was deleted.
|
|
311
|
+
const candidates = (await store.mail.emailRefs({ accountId, since: windowStart.getTime() })).filter(({ mailboxes }) => {
|
|
312
|
+
const inFolders = mailboxes.filter(({ kind }) => kind !== "label" && kind !== ENVELOPE && kind !== BULK);
|
|
313
|
+
return inFolders.length ? inFolders.every(({ externalId }) => folders.includes(externalId)) : allMailOnly;
|
|
314
|
+
});
|
|
315
|
+
const gone = candidates.filter(({ externalId }) => !present.has(externalId)).map(({ externalId }) => externalId);
|
|
316
|
+
skipped = tooManyGone(gone.length, candidates.length);
|
|
317
|
+
if (!skipped && gone.length)
|
|
318
|
+
deleted = await store.mail.markDeleted(accountId, gone, startedAt);
|
|
319
|
+
// Mail an earlier import saved as messages keeps the rule it was saved under until those rows are removed.
|
|
320
|
+
const older = await dropMissing(store, key, { has: (id) => present.has(id.replace(/:attachment:\d+$/, "")) }, windowStart.toISOString(), async ({ id }) => {
|
|
321
|
+
const membership = await store.sync.syncState(key, `mail_message_folders:${id.replace(/:attachment:\d+$/, "")}`);
|
|
322
|
+
if (membership === undefined)
|
|
323
|
+
return allMailOnly;
|
|
324
|
+
return JSON.parse(membership.value).every((folder) => folders.includes(folder));
|
|
325
|
+
});
|
|
326
|
+
deleted += older.deleted;
|
|
327
|
+
skipped ??= older.skipped;
|
|
328
|
+
}
|
|
329
|
+
if (!snapshot)
|
|
330
|
+
await store.mail.setMailboxes(accountId, folders.map(mailbox), new Map([...foundFolders].map(([id, found]) => [id, [...found]])), startedAt);
|
|
331
|
+
// A message's label list is whole, so every label the account has is replaced for each message read.
|
|
332
|
+
if (labelsOf.size && !snapshot) {
|
|
333
|
+
const known = (await store.mail.mailboxes(accountId)).filter(({ kind }) => kind === "label");
|
|
334
|
+
const seen = [...labelsOf.values()].flatMap(({ labels }) => labels ?? []);
|
|
335
|
+
const scanned = new Map([
|
|
336
|
+
...known.map(({ name }) => [name, label(name)]),
|
|
337
|
+
...seen.map((name) => [name, label(name)]),
|
|
338
|
+
]);
|
|
339
|
+
await store.mail.setMailboxes(accountId, [...scanned.values()], new Map([...labelsOf].map(([id, { labels }]) => [id, (labels ?? []).map((name) => label(name).externalId)])), startedAt);
|
|
340
|
+
}
|
|
341
|
+
const attachments = [];
|
|
342
|
+
const unreadable = [];
|
|
343
|
+
let saved = 0;
|
|
344
|
+
let alreadyStored = 0;
|
|
345
|
+
let ruledOut = 0;
|
|
346
|
+
let envelopes = 0;
|
|
347
|
+
const pending = [];
|
|
348
|
+
// IMAP lists what exists now, so a mail marked deleted that it lists again is saved again, which brings it back.
|
|
349
|
+
// An archive is old: what it holds that IMAP proved deleted stays deleted.
|
|
350
|
+
const refs = await store.mail.emailRefs({ accountId, includeDeleted: snapshot });
|
|
351
|
+
const known = new Set(refs.map(({ externalId }) => externalId));
|
|
352
|
+
const enveloped = new Map(refs.flatMap(({ externalId, mailboxes }) => {
|
|
353
|
+
const boxes = mailboxes.filter(({ kind }) => kind === ENVELOPE).map((box) => box.externalId);
|
|
354
|
+
return boxes.length ? [[externalId, boxes]] : [];
|
|
355
|
+
}));
|
|
356
|
+
const upgraded = new Map();
|
|
357
|
+
const toRecord = [];
|
|
358
|
+
// Mail whose outcome is final under these rules; only these UIDs are skipped by the next run's listing.
|
|
359
|
+
const settled = new Set();
|
|
360
|
+
let full = false;
|
|
361
|
+
for (const item of listed) {
|
|
362
|
+
const rule = decide(rules, factsFor(item));
|
|
363
|
+
const boxes = enveloped.get(item.messageId);
|
|
364
|
+
// An envelope whose rule is gone or now keeps the mail is read in full; one a skip now matches stays as it is.
|
|
365
|
+
if (boxes !== undefined && rule?.action !== ENVELOPE && rule?.action !== "skip") {
|
|
366
|
+
if (pending.length < max) {
|
|
367
|
+
upgraded.set(item.messageId, boxes);
|
|
368
|
+
pending.push(item);
|
|
369
|
+
}
|
|
370
|
+
else
|
|
371
|
+
full = true;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (known.has(item.messageId)) {
|
|
375
|
+
alreadyStored++;
|
|
376
|
+
settled.add(item.messageId);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// Stored mail a newer skip or envelope rule matches stays whole: removing it is a separate, explicit step.
|
|
380
|
+
if (rule?.action === "skip") {
|
|
381
|
+
ruledOut++;
|
|
382
|
+
settled.add(item.messageId);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (rule?.action === ENVELOPE) {
|
|
386
|
+
if (!known.has(item.messageId))
|
|
387
|
+
toRecord.push({ item, rule });
|
|
388
|
+
else {
|
|
389
|
+
alreadyStored++;
|
|
390
|
+
settled.add(item.messageId);
|
|
391
|
+
}
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (pending.length < max)
|
|
395
|
+
pending.push(item);
|
|
396
|
+
else
|
|
397
|
+
full = true;
|
|
398
|
+
}
|
|
399
|
+
const inMailboxes = (item) => [
|
|
400
|
+
...[...(foundFolders.get(item.messageId) ?? [])].map(mailbox),
|
|
401
|
+
...(labelsOf.get(item.messageId)?.labels ?? []).map(label),
|
|
402
|
+
];
|
|
403
|
+
const flagsOf = (item) => {
|
|
404
|
+
const { seen, flagged } = labelsOf.get(item.messageId) ?? {};
|
|
405
|
+
return { ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) };
|
|
406
|
+
};
|
|
407
|
+
const uidsOf = new Map();
|
|
408
|
+
for (const item of collected) {
|
|
409
|
+
const folder = item.folder ?? folders[0];
|
|
410
|
+
if (!searches.has(folder))
|
|
411
|
+
continue;
|
|
412
|
+
const inFolder = uidsOf.get(folder) ?? new Map();
|
|
413
|
+
inFolder.set(item.messageId, [...(inFolder.get(item.messageId) ?? []), Number(item.uid)]);
|
|
414
|
+
uidsOf.set(folder, inFolder);
|
|
415
|
+
}
|
|
416
|
+
const saveProgress = async () => {
|
|
417
|
+
for (const [folder, searched] of searches) {
|
|
418
|
+
if (searched.validity === undefined)
|
|
419
|
+
continue;
|
|
420
|
+
const done = [];
|
|
421
|
+
for (const [messageId, uids] of uidsOf.get(folder) ?? [])
|
|
422
|
+
if (settled.has(messageId))
|
|
423
|
+
done.push(...uids);
|
|
424
|
+
const before = earlier.get(folder);
|
|
425
|
+
// A narrower whole listing, like the timer's 30 days, does not replace a wider one that is still fresh.
|
|
426
|
+
const keepFull = before !== undefined &&
|
|
427
|
+
(resumed.has(folder) ||
|
|
428
|
+
(Date.parse(before.full.since) < since.getTime() && startedAt - Date.parse(before.full.at) < FULL_PASS_MS));
|
|
429
|
+
const merged = mergeRanges(before?.done ?? [], rangesOf(done));
|
|
430
|
+
const widest = before === undefined || since.getTime() <= Date.parse(before.window.since);
|
|
431
|
+
const thisRun = { since: since.toISOString(), total: searched.uids.length, at: new Date(startedAt).toISOString() };
|
|
432
|
+
const prior = storedPoints.get(folder);
|
|
433
|
+
const priorWidest = prior?.widest ??
|
|
434
|
+
(prior === undefined
|
|
435
|
+
? undefined
|
|
436
|
+
: { since: prior.window.since, total: prior.window.total, at: prior.updatedAt });
|
|
437
|
+
const widestEver = priorWidest === undefined || since.getTime() <= Date.parse(priorWidest.since) ? thisRun : priorWidest;
|
|
438
|
+
const point = {
|
|
439
|
+
validity: searched.validity,
|
|
440
|
+
done: merged,
|
|
441
|
+
rules: ruleSet,
|
|
442
|
+
full: keepFull ? before.full : { since: since.toISOString(), at: new Date(startedAt).toISOString() },
|
|
443
|
+
window: widest
|
|
444
|
+
? {
|
|
445
|
+
since: since.toISOString(),
|
|
446
|
+
total: searched.uids.length,
|
|
447
|
+
done: searched.uids.filter((uid) => within(merged, Number(uid))).length,
|
|
448
|
+
}
|
|
449
|
+
: before.window,
|
|
450
|
+
widest: widestEver,
|
|
451
|
+
last: { ...thisRun, done: searched.uids.filter((uid) => within(merged, Number(uid))).length },
|
|
452
|
+
updatedAt: new Date(now()).toISOString(),
|
|
453
|
+
};
|
|
454
|
+
await store.sync.setSyncState(key, resumeKey(folder), JSON.stringify(point));
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
let savedAt = now();
|
|
458
|
+
const saveNow = async (force = false) => {
|
|
459
|
+
if (!force && now() - savedAt < SAVE_EVERY_MS)
|
|
460
|
+
return;
|
|
461
|
+
savedAt = now();
|
|
462
|
+
await saveProgress();
|
|
463
|
+
};
|
|
464
|
+
const stopIfAborted = async () => {
|
|
465
|
+
if (!signal?.aborted)
|
|
466
|
+
return;
|
|
467
|
+
await saveNow(true);
|
|
468
|
+
throw signal.reason;
|
|
469
|
+
};
|
|
470
|
+
await saveNow(true);
|
|
471
|
+
// An envelope costs nothing more from the source: its fields were read with the listing.
|
|
472
|
+
for (const { item, rule } of toRecord) {
|
|
473
|
+
await stopIfAborted();
|
|
474
|
+
const headers = item.headers;
|
|
475
|
+
await store.mail.saveThread({
|
|
476
|
+
accountId,
|
|
477
|
+
externalId: item.threadId,
|
|
478
|
+
now: startedAt,
|
|
479
|
+
emails: [
|
|
480
|
+
{
|
|
481
|
+
externalId: item.messageId,
|
|
482
|
+
// Subject and text stay out of the columns search reads, so recorded mail is never a search hit.
|
|
483
|
+
subject: null,
|
|
484
|
+
from: headers?.from === undefined ? null : { address: headers.from, name: headers.fromName ?? null },
|
|
485
|
+
sentAt: null,
|
|
486
|
+
receivedAt: Date.parse(item.receivedAt),
|
|
487
|
+
bodyText: null,
|
|
488
|
+
size: item.size ?? null,
|
|
489
|
+
outgoing: headers?.from === key.account,
|
|
490
|
+
mailboxes: [...inMailboxes(item), envelopeBox(rule.match)],
|
|
491
|
+
...flagsOf(item),
|
|
492
|
+
mailRuleId: rule.id ?? null,
|
|
493
|
+
metadata: {
|
|
494
|
+
rule: { action: rule.action, match: { ...rule.match } },
|
|
495
|
+
...(headers?.subject ? { subject: headers.subject } : {}),
|
|
496
|
+
...(headers?.listId ? { listId: headers.listId } : {}),
|
|
497
|
+
...(headers?.replyTo ? { replyTo: headers.replyTo } : {}),
|
|
498
|
+
...(headers?.auth ? { auth: { ...headers.auth } } : {}),
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
],
|
|
502
|
+
});
|
|
503
|
+
envelopes++;
|
|
504
|
+
settled.add(item.messageId);
|
|
505
|
+
await tagStored(item);
|
|
506
|
+
await saveNow();
|
|
507
|
+
}
|
|
508
|
+
const fetchChunk = plan === undefined ? FETCH_CHUNK : (source.fetchBatch ?? FETCH_SLICE);
|
|
509
|
+
for (let start = 0; start < pending.length; start += fetchChunk) {
|
|
510
|
+
await stopIfAborted();
|
|
511
|
+
const chunk = pending.slice(start, start + fetchChunk);
|
|
512
|
+
if (plan !== undefined) {
|
|
513
|
+
const stop = plan.budget.take();
|
|
514
|
+
if (stop) {
|
|
515
|
+
plan.stopped = stop;
|
|
516
|
+
full = true;
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
let raws;
|
|
521
|
+
try {
|
|
522
|
+
raws = await source.fetchRaw(chunk);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
// What this run saved stays saved: the next one goes on from it.
|
|
526
|
+
await saveNow(true);
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
await stopIfAborted();
|
|
530
|
+
// Every email of a chunk failing is the connection or the login, not the mail: stop instead of trying the rest.
|
|
531
|
+
if (chunk.length > 1 && raws.every((raw) => raw instanceof Error)) {
|
|
532
|
+
await saveNow(true);
|
|
533
|
+
throw raws[0];
|
|
534
|
+
}
|
|
535
|
+
for (const [index, item] of chunk.entries()) {
|
|
536
|
+
let mail;
|
|
537
|
+
try {
|
|
538
|
+
const raw = raws[index];
|
|
539
|
+
if (raw === undefined || raw instanceof Error)
|
|
540
|
+
throw raw ?? new Error("not read");
|
|
541
|
+
mail = await parseMail(raw, maxBodyChars);
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
545
|
+
unreadable.push({ message: item.messageId, reason });
|
|
546
|
+
if (plan !== undefined) {
|
|
547
|
+
plan.unreadable.set(item.messageId, reason);
|
|
548
|
+
plan.failed.add(keyOf(item.folder ?? folders[0], item.uid));
|
|
549
|
+
}
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
plan?.unreadable.delete(item.messageId);
|
|
553
|
+
const header = mailHeadersOf(raws[index]);
|
|
554
|
+
const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
|
|
555
|
+
await store.mail.saveThread({
|
|
556
|
+
accountId,
|
|
557
|
+
externalId: item.threadId,
|
|
558
|
+
now: startedAt,
|
|
559
|
+
emails: [
|
|
560
|
+
{
|
|
561
|
+
externalId: item.messageId,
|
|
562
|
+
subject: mail.subject,
|
|
563
|
+
from: mail.from === null ? null : addressOf(mail.from),
|
|
564
|
+
to: mail.to.map(addressOf),
|
|
565
|
+
cc: mail.cc.map(addressOf),
|
|
566
|
+
bcc: mail.bcc.map(addressOf),
|
|
567
|
+
sentAt: null,
|
|
568
|
+
receivedAt: Date.parse(item.receivedAt),
|
|
569
|
+
bodyText: mail.text || null,
|
|
570
|
+
size: item.size ?? null,
|
|
571
|
+
...(Object.keys(header).length ? { headers: header } : {}),
|
|
572
|
+
outgoing: mail.from === null ? null : mail.from.email === key.account,
|
|
573
|
+
mailboxes: inMailboxes(item),
|
|
574
|
+
...flagsOf(item),
|
|
575
|
+
...(upgraded.has(item.messageId) ? { metadata: {} } : {}),
|
|
576
|
+
mailRuleId: decide(rules, factsFor(item))?.id ?? null,
|
|
577
|
+
attachments: files,
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
});
|
|
581
|
+
saved++;
|
|
582
|
+
settled.add(item.messageId);
|
|
583
|
+
await tagStored(item);
|
|
584
|
+
const boxes = upgraded.get(item.messageId);
|
|
585
|
+
if (boxes !== undefined)
|
|
586
|
+
await store.mail.setMailboxes(accountId, boxes.map(envelopeBoxOf), new Map([[item.messageId, []]]), startedAt);
|
|
587
|
+
}
|
|
588
|
+
await saveNow();
|
|
589
|
+
}
|
|
590
|
+
await saveNow(true);
|
|
591
|
+
if (snapshot && mboxStamp !== undefined && mboxPoint === undefined) {
|
|
592
|
+
const point = {
|
|
593
|
+
...mboxStamp,
|
|
594
|
+
complete: !full && unreadable.length === 0,
|
|
595
|
+
updatedAt: new Date(now()).toISOString(),
|
|
596
|
+
};
|
|
597
|
+
await store.sync.setSyncState(key, MBOX_RESUME, JSON.stringify(point));
|
|
598
|
+
}
|
|
599
|
+
// Read and starred change at the source after import; mail saved in this run already holds them.
|
|
600
|
+
if (!snapshot)
|
|
601
|
+
await store.mail.setFlags(accountId, new Map([...labelsOf].flatMap(([id, { seen, flagged }]) => seen === undefined && flagged === undefined
|
|
602
|
+
? []
|
|
603
|
+
: [[id, { ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) }]])), startedAt);
|
|
604
|
+
const incomplete = plan !== undefined && (capped || plan.stopped !== null || plan.failed.size > 0);
|
|
605
|
+
const complete = !full && !incomplete && unreadable.length === 0 && (plan?.unreadable.size ?? 0) === 0;
|
|
606
|
+
const bulk = new Map(listed.filter((item) => item.headers?.bulk).map((item) => [item.messageId, [bulkBox.externalId]]));
|
|
607
|
+
if (bulk.size)
|
|
608
|
+
await store.mail.setMailboxes(accountId, [bulkBox], bulk, startedAt);
|
|
609
|
+
if (!snapshot)
|
|
610
|
+
await store.sync.setSyncState(key, "mail_folder_scope", scope);
|
|
611
|
+
await stopIfAborted();
|
|
612
|
+
const indexed = await index?.(store, key);
|
|
613
|
+
const coverage = {
|
|
614
|
+
folders,
|
|
615
|
+
since: since.toISOString(),
|
|
616
|
+
until: new Date(startedAt).toISOString(),
|
|
617
|
+
state: complete ? "window" : "partial",
|
|
618
|
+
identity: account.mode === "imap" ? "message-id-or-uidvalidity-folder-uid" : "gmail-message-id",
|
|
619
|
+
};
|
|
620
|
+
if (!snapshot)
|
|
621
|
+
await store.sync.setSyncState(key, "mail_coverage", JSON.stringify(coverage));
|
|
622
|
+
const tagging = tagger.outcome();
|
|
623
|
+
await settleRuleTags(store, accountId, rules, tagging);
|
|
624
|
+
return {
|
|
625
|
+
account: key.account,
|
|
626
|
+
since: since.toISOString(),
|
|
627
|
+
listed: listed.length,
|
|
628
|
+
saved,
|
|
629
|
+
alreadyStored,
|
|
630
|
+
skipped: ruledOut,
|
|
631
|
+
envelopes,
|
|
632
|
+
deleted,
|
|
633
|
+
complete,
|
|
634
|
+
...(indexed === undefined ? {} : { indexed }),
|
|
635
|
+
coverage,
|
|
636
|
+
...(attachments.length ? { attachments } : {}),
|
|
637
|
+
...(unreadable.length ? { unreadable } : {}),
|
|
638
|
+
...(skipped === undefined ? {} : { deletionsSkipped: skipped }),
|
|
639
|
+
...(searches.size > 0 ? { pass: resumed.size > 0 ? "resumed" : "full" } : {}),
|
|
640
|
+
...(tagging.reused.length ? { tagsReused: tagging.reused } : {}),
|
|
641
|
+
...(tagging.skipped.length ? { tagsSkipped: tagging.skipped } : {}),
|
|
642
|
+
};
|
|
643
|
+
};
|
|
644
|
+
/** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
|
|
645
|
+
export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
|
|
646
|
+
/**
|
|
647
|
+
* Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
|
|
648
|
+
* attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
|
|
649
|
+
* resume point moves; the email keeps its stored mailboxes, flags and rule.
|
|
650
|
+
*/
|
|
651
|
+
const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
|
|
652
|
+
const startedAt = now();
|
|
653
|
+
const key = { provider: "email", account: account.address.toLowerCase() };
|
|
654
|
+
const find = source.find;
|
|
655
|
+
if (find === undefined)
|
|
656
|
+
throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
|
|
657
|
+
const accountId = await store.accounts.saveAccount(key, { name: account.address });
|
|
658
|
+
const wanted = (await store.mail.emailRefs({ accountId, attachmentErrors: RETRYABLE_ATTACHMENTS })).map(({ externalId }) => externalId);
|
|
659
|
+
const chosen = wanted.slice(0, max);
|
|
660
|
+
const found = new Map();
|
|
661
|
+
for (const folder of await mailFolders(account, source)) {
|
|
662
|
+
const left = chosen.filter((id) => !found.has(id));
|
|
663
|
+
if (left.length === 0)
|
|
664
|
+
break;
|
|
665
|
+
for (const item of await find(left, folder))
|
|
666
|
+
if (!found.has(item.messageId))
|
|
667
|
+
found.set(item.messageId, item);
|
|
668
|
+
}
|
|
669
|
+
const items = [...found.values()];
|
|
670
|
+
const attachments = [];
|
|
671
|
+
const unreadable = [];
|
|
672
|
+
let emails = 0;
|
|
673
|
+
let extracted = 0;
|
|
674
|
+
let stillFailed = 0;
|
|
675
|
+
for (let start = 0; start < items.length; start += FETCH_CHUNK) {
|
|
676
|
+
const chunk = items.slice(start, start + FETCH_CHUNK);
|
|
677
|
+
const raws = await source.fetchRaw(chunk);
|
|
678
|
+
for (const [index, item] of chunk.entries()) {
|
|
679
|
+
const raw = raws[index];
|
|
680
|
+
const stored = await store.mail.email(accountId, item.messageId);
|
|
681
|
+
if (stored === null)
|
|
682
|
+
continue;
|
|
683
|
+
let mail;
|
|
684
|
+
try {
|
|
685
|
+
if (raw === undefined || raw instanceof Error)
|
|
686
|
+
throw raw ?? new Error("not read");
|
|
687
|
+
mail = await parseMail(raw, maxBodyChars);
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
const before = new Map(stored.attachments.map(({ position, extractionError }) => [position, extractionError]));
|
|
694
|
+
const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
|
|
695
|
+
for (const file of files)
|
|
696
|
+
if (RETRYABLE_ATTACHMENTS.includes(before.get(file.position) ?? ""))
|
|
697
|
+
if (file.extraction === "text")
|
|
698
|
+
extracted++;
|
|
699
|
+
else
|
|
700
|
+
stillFailed++;
|
|
701
|
+
const header = mailHeadersOf(raw);
|
|
702
|
+
await store.mail.saveThread({
|
|
703
|
+
accountId,
|
|
704
|
+
externalId: item.threadId,
|
|
705
|
+
now: startedAt,
|
|
706
|
+
emails: [
|
|
707
|
+
{
|
|
708
|
+
externalId: item.messageId,
|
|
709
|
+
subject: mail.subject,
|
|
710
|
+
from: mail.from === null ? null : addressOf(mail.from),
|
|
711
|
+
to: mail.to.map(addressOf),
|
|
712
|
+
cc: mail.cc.map(addressOf),
|
|
713
|
+
bcc: mail.bcc.map(addressOf),
|
|
714
|
+
sentAt: null,
|
|
715
|
+
receivedAt: Date.parse(item.receivedAt),
|
|
716
|
+
bodyText: mail.text || null,
|
|
717
|
+
size: item.size ?? stored.size,
|
|
718
|
+
...(Object.keys(header).length ? { headers: header } : {}),
|
|
719
|
+
outgoing: mail.from === null ? null : mail.from.email === key.account,
|
|
720
|
+
mailboxes: stored.mailboxes.map(({ externalId, name, kind }) => ({ externalId, name, kind })),
|
|
721
|
+
...(stored.read === null ? {} : { read: stored.read }),
|
|
722
|
+
...(stored.flagged === null ? {} : { flagged: stored.flagged }),
|
|
723
|
+
metadata: stored.metadata,
|
|
724
|
+
mailRuleId: stored.mailRuleId,
|
|
725
|
+
attachments: files,
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
});
|
|
729
|
+
emails++;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const indexed = await index?.(store, key);
|
|
733
|
+
return {
|
|
734
|
+
account: key.account,
|
|
735
|
+
since: since.toISOString(),
|
|
736
|
+
listed: items.length,
|
|
737
|
+
saved: 0,
|
|
738
|
+
alreadyStored: 0,
|
|
739
|
+
skipped: 0,
|
|
740
|
+
envelopes: 0,
|
|
741
|
+
deleted: 0,
|
|
742
|
+
complete: wanted.length <= max && unreadable.length === 0,
|
|
743
|
+
...(indexed === undefined ? {} : { indexed }),
|
|
744
|
+
retried: { emails, extracted, stillFailed, missing: chosen.length - items.length },
|
|
745
|
+
...(attachments.length ? { attachments } : {}),
|
|
746
|
+
...(unreadable.length ? { unreadable } : {}),
|
|
747
|
+
};
|
|
748
|
+
};
|