@wirecat/cli-core 0.487.0 → 0.488.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 +11 -0
- package/dist/mail/attachments.d.ts +15 -0
- package/dist/mail/attachments.js +38 -0
- package/dist/mail/locations.d.ts +28 -0
- package/dist/mail/locations.js +54 -0
- package/dist/mail/quick.d.ts +3 -1
- package/dist/mail/quick.js +9 -6
- package/dist/mail/resume.d.ts +6 -0
- package/dist/mail/retry.d.ts +23 -0
- package/dist/mail/retry.js +110 -0
- package/dist/mail/sync.d.ts +2 -3
- package/dist/mail/sync.js +85 -152
- package/dist/mail/testing/fake.d.ts +2 -0
- package/dist/mail/testing/fake.js +1 -1
- package/dist/store/index.d.ts +1 -1
- package/dist/store/sqlite/accounts.js +1 -0
- package/dist/store/sqlite/email-locations.d.ts +24 -0
- package/dist/store/sqlite/email-locations.js +56 -0
- package/dist/store/sqlite/email-state.d.ts +2 -0
- package/dist/store/sqlite/email-state.js +28 -0
- package/dist/store/sqlite/emails.d.ts +13 -0
- package/dist/store/sqlite/emails.js +9 -29
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +7 -0
- package/dist/store/sqlite/schema.d.ts +72 -0
- package/dist/store/sqlite/schema.js +13 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.488.0 — 30.10.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A quick mail sync proves deletions and refreshes flags again (store version 39).** The store keeps each email's
|
|
15
|
+
place on an IMAP server (`email_locations`: folder, UIDVALIDITY, UID), saved with the email, so a run that lists only
|
|
16
|
+
UIDs and flags can tell which stored mail the server dropped and which flags changed, without reading the mail. The
|
|
17
|
+
first run on mail stored before this lists the folder once with headers to record the places; after that the folder
|
|
18
|
+
is quick. A new UIDVALIDITY drops the folder's places. New `store.mail.folderEmails`, `setLocations` and
|
|
19
|
+
`forgetLocations`, and `locations` on `EmailInput`.
|
|
20
|
+
|
|
10
21
|
## 0.487.0 — 30.10.2026
|
|
11
22
|
|
|
12
23
|
### Added
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LoadEngine } from "../readers/types.js";
|
|
2
|
+
import type { AttachmentInput, EmailAddress } from "../store/index.js";
|
|
3
|
+
import type { Address, ParsedMail } from "./parse.js";
|
|
4
|
+
import type { MailSyncResult } from "./sync.js";
|
|
5
|
+
export declare const MAX_ATTACHMENT_CHARS = 200000;
|
|
6
|
+
/** One chunk of raw mail is held in memory at a time. */
|
|
7
|
+
export declare const FETCH_CHUNK = 50;
|
|
8
|
+
export declare const addressOf: ({ email, name }: Address) => EmailAddress;
|
|
9
|
+
export type AttachmentLog = NonNullable<MailSyncResult["attachments"]>;
|
|
10
|
+
/** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
|
|
11
|
+
export declare const readAttachments: (mail: ParsedMail, messageId: string, { loadEngine, maxAttachmentChars, at }: {
|
|
12
|
+
loadEngine: LoadEngine;
|
|
13
|
+
maxAttachmentChars: number;
|
|
14
|
+
at: number;
|
|
15
|
+
}, log: AttachmentLog) => Promise<AttachmentInput[]>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { extractText } from "../attachments/extract.js";
|
|
2
|
+
export const MAX_ATTACHMENT_CHARS = 200_000;
|
|
3
|
+
/** One chunk of raw mail is held in memory at a time. */
|
|
4
|
+
export const FETCH_CHUNK = 50;
|
|
5
|
+
export const addressOf = ({ email, name }) => ({ address: email, name });
|
|
6
|
+
/** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
|
|
7
|
+
export const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
|
|
8
|
+
const files = [];
|
|
9
|
+
for (const [position, part] of mail.attachments.entries()) {
|
|
10
|
+
const extraction = part.bytes === null
|
|
11
|
+
? { status: "unavailable" }
|
|
12
|
+
: await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
|
|
13
|
+
log.push({ message: messageId, part: part.id, status: extraction.status });
|
|
14
|
+
const extracted = extraction.status === "extracted" ? extraction : undefined;
|
|
15
|
+
files.push({
|
|
16
|
+
position,
|
|
17
|
+
kind: "file",
|
|
18
|
+
mime: part.mime,
|
|
19
|
+
name: part.name,
|
|
20
|
+
title: null,
|
|
21
|
+
url: null,
|
|
22
|
+
size: part.bytes === null ? null : part.bytes.byteLength,
|
|
23
|
+
width: null,
|
|
24
|
+
height: null,
|
|
25
|
+
duration: null,
|
|
26
|
+
providerRef: { message: messageId, part: part.id },
|
|
27
|
+
localPath: null,
|
|
28
|
+
text: extracted ? extracted.text.slice(0, maxAttachmentChars) : null,
|
|
29
|
+
normalizedText: null,
|
|
30
|
+
extraction: extracted ? "text" : "failed",
|
|
31
|
+
extractor: extracted ? extracted.extractor : null,
|
|
32
|
+
extractionError: extracted ? null : extraction.status,
|
|
33
|
+
contentSha256: null,
|
|
34
|
+
extractedAt: at,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return files;
|
|
38
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FolderEmail, MessageStore } from "../store/index.js";
|
|
2
|
+
import type { Quick, Searched } from "./source.js";
|
|
3
|
+
/** The flags a listing shows that differ from the stored ones, by Message-ID, for mail whose UID is stored. */
|
|
4
|
+
export declare const flagsToRefresh: (rows: ReadonlyMap<string, FolderEmail>, listing: readonly Quick[]) => Map<string, {
|
|
5
|
+
read?: boolean;
|
|
6
|
+
flagged?: boolean;
|
|
7
|
+
}>;
|
|
8
|
+
export interface LocationProof {
|
|
9
|
+
/** Each folder listed by UID: what the server searched, and the stored mail with a UID there, before this run saved. */
|
|
10
|
+
folders: ReadonlyMap<string, {
|
|
11
|
+
searched: Searched;
|
|
12
|
+
rows: readonly FolderEmail[];
|
|
13
|
+
}>;
|
|
14
|
+
/** Mail received before this is outside the window IMAP's SINCE can vouch for. */
|
|
15
|
+
windowFrom: number;
|
|
16
|
+
/** Messages this run saw in any folder: one moved to another folder is not gone. */
|
|
17
|
+
present: ReadonlySet<string>;
|
|
18
|
+
now: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A folder listed by UID proves a deletion from the UIDs it stores: mail in the window whose UID the server no
|
|
22
|
+
* longer lists is gone from that folder, and gone altogether when no other location of it is left. Nothing is
|
|
23
|
+
* changed when too many would go, as for a header listing.
|
|
24
|
+
*/
|
|
25
|
+
export declare const proveByLocation: (store: MessageStore, accountId: number, { folders, windowFrom, present, now }: LocationProof, refuse: (gone: number, candidates: number) => string | undefined) => Promise<{
|
|
26
|
+
deleted: number;
|
|
27
|
+
skipped?: string;
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** The flags a listing shows that differ from the stored ones, by Message-ID, for mail whose UID is stored. */
|
|
2
|
+
export const flagsToRefresh = (rows, listing) => {
|
|
3
|
+
const changes = new Map();
|
|
4
|
+
for (const { uid, seen, flagged } of listing) {
|
|
5
|
+
const row = rows.get(uid);
|
|
6
|
+
if (row === undefined)
|
|
7
|
+
continue;
|
|
8
|
+
const changed = {
|
|
9
|
+
...(seen === undefined || seen === row.read ? {} : { read: seen }),
|
|
10
|
+
...(flagged === undefined || flagged === row.flagged ? {} : { flagged }),
|
|
11
|
+
};
|
|
12
|
+
if (Object.keys(changed).length)
|
|
13
|
+
changes.set(row.externalId, changed);
|
|
14
|
+
}
|
|
15
|
+
return changes;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A folder listed by UID proves a deletion from the UIDs it stores: mail in the window whose UID the server no
|
|
19
|
+
* longer lists is gone from that folder, and gone altogether when no other location of it is left. Nothing is
|
|
20
|
+
* changed when too many would go, as for a header listing.
|
|
21
|
+
*/
|
|
22
|
+
export const proveByLocation = async (store, accountId, { folders, windowFrom, present, now }, refuse) => {
|
|
23
|
+
const candidates = new Set();
|
|
24
|
+
const lost = new Map();
|
|
25
|
+
const lostUids = new Map();
|
|
26
|
+
for (const [folder, { searched, rows }] of folders) {
|
|
27
|
+
const inWindow = new Set(searched.uids.map(Number));
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
if (row.receivedAt === null || row.receivedAt < windowFrom)
|
|
30
|
+
continue;
|
|
31
|
+
candidates.add(row.externalId);
|
|
32
|
+
if (inWindow.has(row.uid))
|
|
33
|
+
continue;
|
|
34
|
+
const entry = lost.get(row.externalId) ?? { row, locations: 0, folders: new Set() };
|
|
35
|
+
entry.locations++;
|
|
36
|
+
entry.folders.add(folder);
|
|
37
|
+
lost.set(row.externalId, entry);
|
|
38
|
+
const uids = lostUids.get(folder) ?? [];
|
|
39
|
+
uids.push(row.uid);
|
|
40
|
+
lostUids.set(folder, uids);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const gone = [...lost]
|
|
44
|
+
.filter(([id, entry]) => !present.has(id) && entry.locations === entry.row.locations && entry.folders.size >= entry.row.folders)
|
|
45
|
+
.map(([id]) => id);
|
|
46
|
+
const refused = refuse(gone.length, candidates.size);
|
|
47
|
+
if (refused)
|
|
48
|
+
return { deleted: 0, skipped: refused };
|
|
49
|
+
// Marked first: an interruption leaves locations that prove it again, never an email with no way to be found.
|
|
50
|
+
const deleted = gone.length ? await store.mail.markDeleted(accountId, gone, now) : 0;
|
|
51
|
+
for (const [folder, uids] of lostUids)
|
|
52
|
+
await store.mail.forgetLocations(accountId, folder, folders.get(folder).searched.validity, uids);
|
|
53
|
+
return { deleted };
|
|
54
|
+
};
|
package/dist/mail/quick.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface QuickPlan {
|
|
|
20
20
|
asked: number;
|
|
21
21
|
}
|
|
22
22
|
export interface QuickRead {
|
|
23
|
+
/** Every message of the window the server listed, done or not: its flags refresh the stored mail. */
|
|
24
|
+
listing: Quick[];
|
|
23
25
|
/** Messages read and identified, with their raw bytes so the save does not read them again. */
|
|
24
26
|
read: {
|
|
25
27
|
item: Listed;
|
|
@@ -36,4 +38,4 @@ export interface QuickRead {
|
|
|
36
38
|
* One round of a folder whose server gives no cheap message id: UID, flags, date and size of what is not done, then
|
|
37
39
|
* the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
|
|
38
40
|
*/
|
|
39
|
-
export declare const readQuick: (source: MailSource & Required<Pick<MailSource, "quick" | "identify">>, since: Date, folder: string, searched: Searched, done: Ranges, max: number, plan: QuickPlan | undefined) => Promise<QuickRead>;
|
|
41
|
+
export declare const readQuick: (source: MailSource & Required<Pick<MailSource, "quick" | "identify">>, since: Date, folder: string, searched: Searched, done: Ranges, max: number, plan: QuickPlan | undefined, located: ReadonlySet<string>) => Promise<QuickRead>;
|
package/dist/mail/quick.js
CHANGED
|
@@ -6,16 +6,18 @@ export const keyOf = (folder, uid) => `${folder}\0${uid}`;
|
|
|
6
6
|
* One round of a folder whose server gives no cheap message id: UID, flags, date and size of what is not done, then
|
|
7
7
|
* the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
|
|
8
8
|
*/
|
|
9
|
-
export const readQuick = async (source, since, folder, searched, done, max, plan) => {
|
|
10
|
-
const answer = { read: [], unreadable: [], capped: false };
|
|
9
|
+
export const readQuick = async (source, since, folder, searched, done, max, plan, located) => {
|
|
10
|
+
const answer = { listing: [], read: [], unreadable: [], capped: false };
|
|
11
11
|
let items = plan?.quick.get(folder);
|
|
12
12
|
if (items === undefined) {
|
|
13
|
-
|
|
13
|
+
// Mail whose location is known is listed for its flags only; it is never read again.
|
|
14
|
+
const asked = searched.uids.filter((uid) => plan?.only === undefined || plan.only.has(keyOf(folder, uid)));
|
|
15
|
+
const wanted = asked.filter((uid) => !within(done, Number(uid)) && !located.has(uid));
|
|
14
16
|
if (asked.length > 0 && plan) {
|
|
15
17
|
const stop = plan.budget.take();
|
|
16
18
|
if (stop) {
|
|
17
19
|
plan.stopped = stop;
|
|
18
|
-
plan.remaining +=
|
|
20
|
+
plan.remaining += wanted.length;
|
|
19
21
|
return { ...answer, capped: true };
|
|
20
22
|
}
|
|
21
23
|
}
|
|
@@ -23,15 +25,16 @@ export const readQuick = async (source, since, folder, searched, done, max, plan
|
|
|
23
25
|
if (plan !== undefined) {
|
|
24
26
|
plan.quick.set(folder, items);
|
|
25
27
|
const got = new Set(items.map(({ uid }) => uid));
|
|
26
|
-
for (const uid of
|
|
28
|
+
for (const uid of wanted)
|
|
27
29
|
if (!got.has(uid)) {
|
|
28
30
|
plan.failed.add(keyOf(folder, uid));
|
|
29
31
|
plan.unlisted.add(keyOf(folder, uid));
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
}
|
|
35
|
+
answer.listing = items;
|
|
33
36
|
const open = items
|
|
34
|
-
.filter(({ uid }) => !within(done, Number(uid)) && !plan?.tried.has(keyOf(folder, uid)))
|
|
37
|
+
.filter(({ uid }) => !within(done, Number(uid)) && !located.has(uid) && !plan?.tried.has(keyOf(folder, uid)))
|
|
35
38
|
.sort((a, b) => Number(b.uid) - Number(a.uid));
|
|
36
39
|
const slice = open.slice(0, plan === undefined ? max : (source.fetchBatch ?? FETCH_SLICE));
|
|
37
40
|
if (plan !== undefined)
|
package/dist/mail/resume.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface ResumePoint {
|
|
|
30
30
|
total: number;
|
|
31
31
|
at: string;
|
|
32
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* From this date on, every stored email of the folder has its UID in `email_locations`: a listing that gives UIDs
|
|
35
|
+
* and flags is enough to refresh flags and prove deletions. Absent after an engine that did not know locations
|
|
36
|
+
* wrote the point, and then one header listing records them.
|
|
37
|
+
*/
|
|
38
|
+
located?: string;
|
|
33
39
|
/** What the latest run listed, whatever its window. */
|
|
34
40
|
last?: {
|
|
35
41
|
since: string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { LoadEngine } from "../readers/types.js";
|
|
2
|
+
import type { MessageStore } from "../store/index.js";
|
|
3
|
+
import type { MailSource } from "./source.js";
|
|
4
|
+
import type { MailAccount, MailSyncOptions, MailSyncResult } from "./sync.js";
|
|
5
|
+
/** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
|
|
6
|
+
export declare const RETRYABLE_ATTACHMENTS: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
|
|
9
|
+
* attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
|
|
10
|
+
* resume point moves; the email keeps its stored mailboxes, flags and rule.
|
|
11
|
+
*/
|
|
12
|
+
export declare const retryUnread: ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }: {
|
|
13
|
+
store: MessageStore;
|
|
14
|
+
source: MailSource;
|
|
15
|
+
account: MailAccount;
|
|
16
|
+
since: Date;
|
|
17
|
+
max: number;
|
|
18
|
+
maxBodyChars: number;
|
|
19
|
+
maxAttachmentChars: number;
|
|
20
|
+
now: () => number;
|
|
21
|
+
loadEngine: LoadEngine;
|
|
22
|
+
index: MailSyncOptions["index"];
|
|
23
|
+
}) => Promise<MailSyncResult>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { addressOf, FETCH_CHUNK, readAttachments } from "./attachments.js";
|
|
3
|
+
import { mailFolders } from "./folders.js";
|
|
4
|
+
import { mailHeadersOf } from "./headers.js";
|
|
5
|
+
import { parseMail } from "./parse.js";
|
|
6
|
+
/** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
|
|
7
|
+
export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
|
|
8
|
+
/**
|
|
9
|
+
* Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
|
|
10
|
+
* attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
|
|
11
|
+
* resume point moves; the email keeps its stored mailboxes, flags and rule.
|
|
12
|
+
*/
|
|
13
|
+
export const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
|
|
14
|
+
const startedAt = now();
|
|
15
|
+
const key = { provider: "email", account: account.address.toLowerCase() };
|
|
16
|
+
const find = source.find;
|
|
17
|
+
if (find === undefined)
|
|
18
|
+
throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
|
|
19
|
+
const accountId = await store.accounts.saveAccount(key, { name: account.address });
|
|
20
|
+
const wanted = (await store.mail.emailRefs({ accountId, attachmentErrors: RETRYABLE_ATTACHMENTS })).map(({ externalId }) => externalId);
|
|
21
|
+
const chosen = wanted.slice(0, max);
|
|
22
|
+
const found = new Map();
|
|
23
|
+
for (const folder of await mailFolders(account, source)) {
|
|
24
|
+
const left = chosen.filter((id) => !found.has(id));
|
|
25
|
+
if (left.length === 0)
|
|
26
|
+
break;
|
|
27
|
+
for (const item of await find(left, folder))
|
|
28
|
+
if (!found.has(item.messageId))
|
|
29
|
+
found.set(item.messageId, item);
|
|
30
|
+
}
|
|
31
|
+
const items = [...found.values()];
|
|
32
|
+
const attachments = [];
|
|
33
|
+
const unreadable = [];
|
|
34
|
+
let emails = 0;
|
|
35
|
+
let extracted = 0;
|
|
36
|
+
let stillFailed = 0;
|
|
37
|
+
for (let start = 0; start < items.length; start += FETCH_CHUNK) {
|
|
38
|
+
const chunk = items.slice(start, start + FETCH_CHUNK);
|
|
39
|
+
const raws = await source.fetchRaw(chunk);
|
|
40
|
+
for (const [index, item] of chunk.entries()) {
|
|
41
|
+
const raw = raws[index];
|
|
42
|
+
const stored = await store.mail.email(accountId, item.messageId);
|
|
43
|
+
if (stored === null)
|
|
44
|
+
continue;
|
|
45
|
+
let mail;
|
|
46
|
+
try {
|
|
47
|
+
if (raw === undefined || raw instanceof Error)
|
|
48
|
+
throw raw ?? new Error("not read");
|
|
49
|
+
mail = await parseMail(raw, maxBodyChars);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const before = new Map(stored.attachments.map(({ position, extractionError }) => [position, extractionError]));
|
|
56
|
+
const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
|
|
57
|
+
for (const file of files)
|
|
58
|
+
if (RETRYABLE_ATTACHMENTS.includes(before.get(file.position) ?? ""))
|
|
59
|
+
if (file.extraction === "text")
|
|
60
|
+
extracted++;
|
|
61
|
+
else
|
|
62
|
+
stillFailed++;
|
|
63
|
+
const header = mailHeadersOf(raw);
|
|
64
|
+
await store.mail.saveThread({
|
|
65
|
+
accountId,
|
|
66
|
+
externalId: item.threadId,
|
|
67
|
+
now: startedAt,
|
|
68
|
+
emails: [
|
|
69
|
+
{
|
|
70
|
+
externalId: item.messageId,
|
|
71
|
+
subject: mail.subject,
|
|
72
|
+
from: mail.from === null ? null : addressOf(mail.from),
|
|
73
|
+
to: mail.to.map(addressOf),
|
|
74
|
+
cc: mail.cc.map(addressOf),
|
|
75
|
+
bcc: mail.bcc.map(addressOf),
|
|
76
|
+
sentAt: null,
|
|
77
|
+
receivedAt: Date.parse(item.receivedAt),
|
|
78
|
+
bodyText: mail.text || null,
|
|
79
|
+
size: item.size ?? stored.size,
|
|
80
|
+
...(Object.keys(header).length ? { headers: header } : {}),
|
|
81
|
+
outgoing: mail.from === null ? null : mail.from.email === key.account,
|
|
82
|
+
mailboxes: stored.mailboxes.map(({ externalId, name, kind }) => ({ externalId, name, kind })),
|
|
83
|
+
...(stored.read === null ? {} : { read: stored.read }),
|
|
84
|
+
...(stored.flagged === null ? {} : { flagged: stored.flagged }),
|
|
85
|
+
metadata: stored.metadata,
|
|
86
|
+
mailRuleId: stored.mailRuleId,
|
|
87
|
+
attachments: files,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
emails++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const indexed = await index?.(store, key);
|
|
95
|
+
return {
|
|
96
|
+
account: key.account,
|
|
97
|
+
since: since.toISOString(),
|
|
98
|
+
listed: items.length,
|
|
99
|
+
saved: 0,
|
|
100
|
+
alreadyStored: 0,
|
|
101
|
+
skipped: 0,
|
|
102
|
+
envelopes: 0,
|
|
103
|
+
deleted: 0,
|
|
104
|
+
complete: wanted.length <= max && unreadable.length === 0,
|
|
105
|
+
...(indexed === undefined ? {} : { indexed }),
|
|
106
|
+
retried: { emails, extracted, stillFailed, missing: chosen.length - items.length },
|
|
107
|
+
...(attachments.length ? { attachments } : {}),
|
|
108
|
+
...(unreadable.length ? { unreadable } : {}),
|
|
109
|
+
};
|
|
110
|
+
};
|
package/dist/mail/sync.d.ts
CHANGED
|
@@ -74,7 +74,8 @@ export interface MailSyncResult {
|
|
|
74
74
|
missing: number;
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
export
|
|
77
|
+
export { MAX_ATTACHMENT_CHARS } from "./attachments.js";
|
|
78
|
+
export { RETRYABLE_ATTACHMENTS } from "./retry.js";
|
|
78
79
|
export interface MailSyncOptions {
|
|
79
80
|
account: MailAccount;
|
|
80
81
|
since: Date;
|
|
@@ -102,5 +103,3 @@ export interface MailSyncOptions {
|
|
|
102
103
|
budget?: RunBudget;
|
|
103
104
|
}
|
|
104
105
|
export declare const syncMail: (source: MailSource, store: MessageStore, options: MailSyncOptions) => Promise<MailSyncResult>;
|
|
105
|
-
/** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
|
|
106
|
-
export declare const RETRYABLE_ATTACHMENTS: string[];
|
package/dist/mail/sync.js
CHANGED
|
@@ -1,59 +1,27 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import {
|
|
2
|
+
import { importEngine } from "../attachments/extract.js";
|
|
3
|
+
import { addressOf, FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
|
|
3
4
|
import { BULK, bulkBox } from "./bulk.js";
|
|
4
5
|
import { factsOf } from "./facts.js";
|
|
5
6
|
import { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
|
|
6
7
|
import { dropMissing, tooManyGone } from "./gone.js";
|
|
7
8
|
import { mailHeadersOf } from "./headers.js";
|
|
9
|
+
import { flagsToRefresh, proveByLocation } from "./locations.js";
|
|
8
10
|
import { MAX_BODY_CHARS, parseMail } from "./parse.js";
|
|
9
11
|
import { FETCH_SLICE, keyOf, readQuick } from "./quick.js";
|
|
10
12
|
import { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
|
|
13
|
+
import { retryUnread } from "./retry.js";
|
|
11
14
|
import { ruleTagger, settleRuleTags } from "./rule-tags.js";
|
|
12
15
|
import { decide, ENVELOPE, envelopeBox, envelopeBoxOf, tagsOf } from "./rules.js";
|
|
13
16
|
const DAY = 86_400_000;
|
|
14
17
|
/** IMAP's SINCE counts whole days in the server's zone, so the window's first days are not trusted. */
|
|
15
18
|
const EDGE = 2 * DAY;
|
|
16
|
-
export const MAX_ATTACHMENT_CHARS = 200_000;
|
|
17
|
-
/** One chunk of raw mail is held in memory at a time. */
|
|
18
|
-
const FETCH_CHUNK = 50;
|
|
19
19
|
/** How often a long run saves where it is, so a kill costs at most this much listing again. */
|
|
20
20
|
const SAVE_EVERY_MS = 30_000;
|
|
21
21
|
/** About a minute of listing; the 30-day window of the timer stays below it. */
|
|
22
22
|
const RESUME_FROM = 5_000;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
|
|
26
|
-
const files = [];
|
|
27
|
-
for (const [position, part] of mail.attachments.entries()) {
|
|
28
|
-
const extraction = part.bytes === null
|
|
29
|
-
? { status: "unavailable" }
|
|
30
|
-
: await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
|
|
31
|
-
log.push({ message: messageId, part: part.id, status: extraction.status });
|
|
32
|
-
const extracted = extraction.status === "extracted" ? extraction : undefined;
|
|
33
|
-
files.push({
|
|
34
|
-
position,
|
|
35
|
-
kind: "file",
|
|
36
|
-
mime: part.mime,
|
|
37
|
-
name: part.name,
|
|
38
|
-
title: null,
|
|
39
|
-
url: null,
|
|
40
|
-
size: part.bytes === null ? null : part.bytes.byteLength,
|
|
41
|
-
width: null,
|
|
42
|
-
height: null,
|
|
43
|
-
duration: null,
|
|
44
|
-
providerRef: { message: messageId, part: part.id },
|
|
45
|
-
localPath: null,
|
|
46
|
-
text: extracted ? extracted.text.slice(0, maxAttachmentChars) : null,
|
|
47
|
-
normalizedText: null,
|
|
48
|
-
extraction: extracted ? "text" : "failed",
|
|
49
|
-
extractor: extracted ? extracted.extractor : null,
|
|
50
|
-
extractionError: extracted ? null : extraction.status,
|
|
51
|
-
contentSha256: null,
|
|
52
|
-
extractedAt: at,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
return files;
|
|
56
|
-
};
|
|
23
|
+
export { MAX_ATTACHMENT_CHARS } from "./attachments.js";
|
|
24
|
+
export { RETRYABLE_ATTACHMENTS } from "./retry.js";
|
|
57
25
|
const LIST_SLICE = 200;
|
|
58
26
|
export const syncMail = async (source, store, options) => {
|
|
59
27
|
const { budget, index } = options;
|
|
@@ -194,6 +162,11 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
194
162
|
/** Raw mail read to learn its id, kept for the save so it is not read twice. */
|
|
195
163
|
const rawOf = new Map();
|
|
196
164
|
const quickRan = new Set();
|
|
165
|
+
/** Folders whose server gives no cheap id: their mail's UIDs are kept, so a UID listing can stand in for headers. */
|
|
166
|
+
const locatable = new Set();
|
|
167
|
+
/** A quick folder's stored mail as it was before this run changed anything, with the UID of each. */
|
|
168
|
+
const folderMail = new Map();
|
|
169
|
+
const refreshed = new Map();
|
|
197
170
|
let holding;
|
|
198
171
|
const holdsMail = async (folder) => {
|
|
199
172
|
holding ??= new Set((await store.mail.emailRefs({ accountId })).flatMap(({ mailboxes }) => mailboxes
|
|
@@ -234,10 +207,16 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
234
207
|
searched.validity !== undefined &&
|
|
235
208
|
source.quick !== undefined &&
|
|
236
209
|
source.identify !== undefined;
|
|
210
|
+
if (quickable)
|
|
211
|
+
locatable.add(folder);
|
|
212
|
+
// The UID listing proves deletions only for mail whose UID is stored; a folder stored before locations existed
|
|
213
|
+
// gets them from one header listing.
|
|
214
|
+
const located = point?.located !== undefined && Date.parse(point.located) <= since.getTime();
|
|
215
|
+
const quickReady = async () => (sameRules && located) || !(await holdsMail(folder));
|
|
237
216
|
const inQuick = quickable &&
|
|
238
217
|
(plan === undefined
|
|
239
|
-
?
|
|
240
|
-
: plan.quickFolders.has(folder) || (!plan.modes.has(folder) && (
|
|
218
|
+
? await quickReady()
|
|
219
|
+
: plan.quickFolders.has(folder) || (!plan.modes.has(folder) && (await quickReady())));
|
|
241
220
|
if (inQuick) {
|
|
242
221
|
plan?.quickFolders.add(folder);
|
|
243
222
|
plan?.modes.set(folder, "resumed");
|
|
@@ -246,7 +225,12 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
246
225
|
const done = point !== undefined && sameRules ? point.done : [];
|
|
247
226
|
if (point !== undefined && sameRules)
|
|
248
227
|
earlier.set(folder, point);
|
|
249
|
-
const
|
|
228
|
+
const rows = await store.mail.folderEmails(accountId, folder, searched.validity);
|
|
229
|
+
folderMail.set(folder, rows);
|
|
230
|
+
const byUid = new Map(rows.map((row) => [String(row.uid), row]));
|
|
231
|
+
const round = await readQuick(source, since, folder, searched, done, max, plan, new Set(byUid.keys()));
|
|
232
|
+
for (const [id, flags] of flagsToRefresh(byUid, round.listing))
|
|
233
|
+
refreshed.set(id, { ...refreshed.get(id), ...flags });
|
|
250
234
|
if (round.capped)
|
|
251
235
|
capped = true;
|
|
252
236
|
for (const { item, raw } of round.read) {
|
|
@@ -260,12 +244,13 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
260
244
|
if (point !== undefined && point.validity === searched.validity && point.rules === ruleSet) {
|
|
261
245
|
earlier.set(folder, point);
|
|
262
246
|
const mode = plan?.modes.get(folder);
|
|
247
|
+
const backfill = quickable && !located;
|
|
263
248
|
const usable = Date.parse(point.full.since) <= since.getTime() && startedAt - Date.parse(point.full.at) < FULL_PASS_MS;
|
|
264
249
|
// A budgeted run goes on with an unfinished pass however small its window is: listing it whole again would
|
|
265
250
|
// ask for the same newest messages every run.
|
|
266
251
|
const unfinished = plan !== undefined && searched.uids.some((uid) => !within(point.done, Number(uid)));
|
|
267
252
|
if (mode === "resumed" ||
|
|
268
|
-
(mode === undefined && usable && (searched.uids.length >= resumeFrom || unfinished))) {
|
|
253
|
+
(mode === undefined && usable && !backfill && (searched.uids.length >= resumeFrom || unfinished))) {
|
|
269
254
|
resumed.add(folder);
|
|
270
255
|
wanted = { ...searched, uids: searched.uids.filter((uid) => !within(point.done, Number(uid))) };
|
|
271
256
|
}
|
|
@@ -332,6 +317,22 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
332
317
|
current.add(item.folder ?? folders[0]);
|
|
333
318
|
foundFolders.set(item.messageId, current);
|
|
334
319
|
}
|
|
320
|
+
const listingWhole = !capped && (plan === undefined || (plan.stopped === null && plan.failed.size === 0 && plan.only === undefined));
|
|
321
|
+
/** Where each message was seen this run, saved with it so a message saved is a message located. */
|
|
322
|
+
const locationsOf = new Map();
|
|
323
|
+
for (const item of collected) {
|
|
324
|
+
const folder = item.folder ?? folders[0];
|
|
325
|
+
const validity = searches.get(folder)?.validity;
|
|
326
|
+
if (!locatable.has(folder) || validity === undefined)
|
|
327
|
+
continue;
|
|
328
|
+
const where = locationsOf.get(item.messageId) ?? [];
|
|
329
|
+
where.push({ folder, uidvalidity: validity, uid: Number(item.uid) });
|
|
330
|
+
locationsOf.set(item.messageId, where);
|
|
331
|
+
}
|
|
332
|
+
const locationOf = (item) => {
|
|
333
|
+
const locations = locationsOf.get(item.messageId);
|
|
334
|
+
return locations === undefined ? {} : { locations };
|
|
335
|
+
};
|
|
335
336
|
const mailbox = (folder) => ({ externalId: folder, name: folder, kind: null });
|
|
336
337
|
const label = (name) => ({ externalId: `label:${name}`, name, kind: "label" });
|
|
337
338
|
const labelsOf = new Map(collected.flatMap((item) => (item.labels === undefined ? [] : [[item.messageId, item]])));
|
|
@@ -346,8 +347,23 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
346
347
|
skipped = "an mbox file is a snapshot; mail missing from it is not proven deleted";
|
|
347
348
|
else if (plan !== undefined && (capped || plan.tried.size > plan.asked || plan.only !== undefined))
|
|
348
349
|
skipped = "this run read the window in parts; a run that lists all of it at once checks deletions";
|
|
349
|
-
else if (quickRan.size > 0)
|
|
350
|
-
|
|
350
|
+
else if (quickRan.size > 0) {
|
|
351
|
+
const proof = await proveByLocation(store, accountId, {
|
|
352
|
+
folders: new Map([...quickRan].map((folder) => [
|
|
353
|
+
folder,
|
|
354
|
+
{ searched: searches.get(folder), rows: folderMail.get(folder) ?? [] },
|
|
355
|
+
])),
|
|
356
|
+
windowFrom: windowStart.getTime(),
|
|
357
|
+
present,
|
|
358
|
+
now: startedAt,
|
|
359
|
+
}, tooManyGone);
|
|
360
|
+
deleted = proof.deleted;
|
|
361
|
+
skipped =
|
|
362
|
+
proof.skipped ??
|
|
363
|
+
(quickRan.size < folders.length
|
|
364
|
+
? "folders this run did not list by UID are not checked for deletions; --rescan lists every message and checks them"
|
|
365
|
+
: undefined);
|
|
366
|
+
}
|
|
351
367
|
else if (resumed.size > 0)
|
|
352
368
|
skipped =
|
|
353
369
|
"this run listed only mail not read before; a full listing, at least daily or with --rescan, checks deletions";
|
|
@@ -481,6 +497,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
481
497
|
? undefined
|
|
482
498
|
: { since: prior.window.since, total: prior.window.total, at: prior.updatedAt });
|
|
483
499
|
const widestEver = priorWidest === undefined || since.getTime() <= Date.parse(priorWidest.since) ? thisRun : priorWidest;
|
|
500
|
+
const located = locatable.has(folder) && (quickRan.has(folder) || (!resumed.has(folder) && listingWhole));
|
|
501
|
+
const locatedFrom = before?.located === undefined ? since.toISOString() : before.located;
|
|
484
502
|
const point = {
|
|
485
503
|
validity: searched.validity,
|
|
486
504
|
done: merged,
|
|
@@ -494,12 +512,22 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
494
512
|
}
|
|
495
513
|
: before.window,
|
|
496
514
|
widest: widestEver,
|
|
515
|
+
...(located
|
|
516
|
+
? { located: Date.parse(locatedFrom) < since.getTime() ? locatedFrom : since.toISOString() }
|
|
517
|
+
: before?.located === undefined
|
|
518
|
+
? {}
|
|
519
|
+
: { located: before.located }),
|
|
497
520
|
last: { ...thisRun, done: searched.uids.filter((uid) => within(merged, Number(uid))).length },
|
|
498
521
|
updatedAt: new Date(now()).toISOString(),
|
|
499
522
|
};
|
|
500
523
|
await store.sync.setSyncState(key, resumeKey(folder), JSON.stringify(point));
|
|
501
524
|
}
|
|
502
525
|
};
|
|
526
|
+
// Mail already stored is located before its UID is marked done, so a done UID always has its place.
|
|
527
|
+
for (const folder of locatable) {
|
|
528
|
+
const searched = searches.get(folder);
|
|
529
|
+
await store.mail.setLocations(accountId, folder, searched.validity, uidsOf.get(folder) ?? new Map());
|
|
530
|
+
}
|
|
503
531
|
let savedAt = now();
|
|
504
532
|
const saveNow = async (force = false) => {
|
|
505
533
|
if (!force && now() - savedAt < SAVE_EVERY_MS)
|
|
@@ -534,6 +562,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
534
562
|
size: item.size ?? null,
|
|
535
563
|
outgoing: headers?.from === key.account,
|
|
536
564
|
mailboxes: [...inMailboxes(item), envelopeBox(rule.match)],
|
|
565
|
+
...locationOf(item),
|
|
537
566
|
...flagsOf(item),
|
|
538
567
|
mailRuleId: rule.id ?? null,
|
|
539
568
|
metadata: {
|
|
@@ -620,6 +649,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
620
649
|
...(Object.keys(header).length ? { headers: header } : {}),
|
|
621
650
|
outgoing: mail.from === null ? null : mail.from.email === key.account,
|
|
622
651
|
mailboxes: inMailboxes(item),
|
|
652
|
+
...locationOf(item),
|
|
623
653
|
...flagsOf(item),
|
|
624
654
|
...(upgraded.has(item.messageId) ? { metadata: {} } : {}),
|
|
625
655
|
mailRuleId: decide(rules, factsFor(item))?.id ?? null,
|
|
@@ -647,9 +677,17 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
647
677
|
}
|
|
648
678
|
// Read and starred change at the source after import; mail saved in this run already holds them.
|
|
649
679
|
if (!snapshot)
|
|
650
|
-
await store.mail.setFlags(accountId, new Map([
|
|
651
|
-
|
|
652
|
-
|
|
680
|
+
await store.mail.setFlags(accountId, new Map([
|
|
681
|
+
...refreshed,
|
|
682
|
+
...[...labelsOf].flatMap(([id, { seen, flagged }]) => seen === undefined && flagged === undefined
|
|
683
|
+
? []
|
|
684
|
+
: [
|
|
685
|
+
[
|
|
686
|
+
id,
|
|
687
|
+
{ ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) },
|
|
688
|
+
],
|
|
689
|
+
]),
|
|
690
|
+
]), startedAt);
|
|
653
691
|
const incomplete = capped || (plan !== undefined && (plan.stopped !== null || plan.failed.size > 0));
|
|
654
692
|
const complete = !full && !incomplete && unreadable.length === 0 && (plan?.unreadable.size ?? 0) === 0;
|
|
655
693
|
const bulk = new Map(listed.filter((item) => item.headers?.bulk).map((item) => [item.messageId, [bulkBox.externalId]]));
|
|
@@ -690,108 +728,3 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
690
728
|
...(tagging.skipped.length ? { tagsSkipped: tagging.skipped } : {}),
|
|
691
729
|
};
|
|
692
730
|
};
|
|
693
|
-
/** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
|
|
694
|
-
export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
|
|
695
|
-
/**
|
|
696
|
-
* Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
|
|
697
|
-
* attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
|
|
698
|
-
* resume point moves; the email keeps its stored mailboxes, flags and rule.
|
|
699
|
-
*/
|
|
700
|
-
const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
|
|
701
|
-
const startedAt = now();
|
|
702
|
-
const key = { provider: "email", account: account.address.toLowerCase() };
|
|
703
|
-
const find = source.find;
|
|
704
|
-
if (find === undefined)
|
|
705
|
-
throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
|
|
706
|
-
const accountId = await store.accounts.saveAccount(key, { name: account.address });
|
|
707
|
-
const wanted = (await store.mail.emailRefs({ accountId, attachmentErrors: RETRYABLE_ATTACHMENTS })).map(({ externalId }) => externalId);
|
|
708
|
-
const chosen = wanted.slice(0, max);
|
|
709
|
-
const found = new Map();
|
|
710
|
-
for (const folder of await mailFolders(account, source)) {
|
|
711
|
-
const left = chosen.filter((id) => !found.has(id));
|
|
712
|
-
if (left.length === 0)
|
|
713
|
-
break;
|
|
714
|
-
for (const item of await find(left, folder))
|
|
715
|
-
if (!found.has(item.messageId))
|
|
716
|
-
found.set(item.messageId, item);
|
|
717
|
-
}
|
|
718
|
-
const items = [...found.values()];
|
|
719
|
-
const attachments = [];
|
|
720
|
-
const unreadable = [];
|
|
721
|
-
let emails = 0;
|
|
722
|
-
let extracted = 0;
|
|
723
|
-
let stillFailed = 0;
|
|
724
|
-
for (let start = 0; start < items.length; start += FETCH_CHUNK) {
|
|
725
|
-
const chunk = items.slice(start, start + FETCH_CHUNK);
|
|
726
|
-
const raws = await source.fetchRaw(chunk);
|
|
727
|
-
for (const [index, item] of chunk.entries()) {
|
|
728
|
-
const raw = raws[index];
|
|
729
|
-
const stored = await store.mail.email(accountId, item.messageId);
|
|
730
|
-
if (stored === null)
|
|
731
|
-
continue;
|
|
732
|
-
let mail;
|
|
733
|
-
try {
|
|
734
|
-
if (raw === undefined || raw instanceof Error)
|
|
735
|
-
throw raw ?? new Error("not read");
|
|
736
|
-
mail = await parseMail(raw, maxBodyChars);
|
|
737
|
-
}
|
|
738
|
-
catch (error) {
|
|
739
|
-
unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
|
|
740
|
-
continue;
|
|
741
|
-
}
|
|
742
|
-
const before = new Map(stored.attachments.map(({ position, extractionError }) => [position, extractionError]));
|
|
743
|
-
const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
|
|
744
|
-
for (const file of files)
|
|
745
|
-
if (RETRYABLE_ATTACHMENTS.includes(before.get(file.position) ?? ""))
|
|
746
|
-
if (file.extraction === "text")
|
|
747
|
-
extracted++;
|
|
748
|
-
else
|
|
749
|
-
stillFailed++;
|
|
750
|
-
const header = mailHeadersOf(raw);
|
|
751
|
-
await store.mail.saveThread({
|
|
752
|
-
accountId,
|
|
753
|
-
externalId: item.threadId,
|
|
754
|
-
now: startedAt,
|
|
755
|
-
emails: [
|
|
756
|
-
{
|
|
757
|
-
externalId: item.messageId,
|
|
758
|
-
subject: mail.subject,
|
|
759
|
-
from: mail.from === null ? null : addressOf(mail.from),
|
|
760
|
-
to: mail.to.map(addressOf),
|
|
761
|
-
cc: mail.cc.map(addressOf),
|
|
762
|
-
bcc: mail.bcc.map(addressOf),
|
|
763
|
-
sentAt: null,
|
|
764
|
-
receivedAt: Date.parse(item.receivedAt),
|
|
765
|
-
bodyText: mail.text || null,
|
|
766
|
-
size: item.size ?? stored.size,
|
|
767
|
-
...(Object.keys(header).length ? { headers: header } : {}),
|
|
768
|
-
outgoing: mail.from === null ? null : mail.from.email === key.account,
|
|
769
|
-
mailboxes: stored.mailboxes.map(({ externalId, name, kind }) => ({ externalId, name, kind })),
|
|
770
|
-
...(stored.read === null ? {} : { read: stored.read }),
|
|
771
|
-
...(stored.flagged === null ? {} : { flagged: stored.flagged }),
|
|
772
|
-
metadata: stored.metadata,
|
|
773
|
-
mailRuleId: stored.mailRuleId,
|
|
774
|
-
attachments: files,
|
|
775
|
-
},
|
|
776
|
-
],
|
|
777
|
-
});
|
|
778
|
-
emails++;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
const indexed = await index?.(store, key);
|
|
782
|
-
return {
|
|
783
|
-
account: key.account,
|
|
784
|
-
since: since.toISOString(),
|
|
785
|
-
listed: items.length,
|
|
786
|
-
saved: 0,
|
|
787
|
-
alreadyStored: 0,
|
|
788
|
-
skipped: 0,
|
|
789
|
-
envelopes: 0,
|
|
790
|
-
deleted: 0,
|
|
791
|
-
complete: wanted.length <= max && unreadable.length === 0,
|
|
792
|
-
...(indexed === undefined ? {} : { indexed }),
|
|
793
|
-
retried: { emails, extracted, stillFailed, missing: chosen.length - items.length },
|
|
794
|
-
...(attachments.length ? { attachments } : {}),
|
|
795
|
-
...(unreadable.length ? { unreadable } : {}),
|
|
796
|
-
};
|
|
797
|
-
};
|
|
@@ -26,6 +26,8 @@ export interface FakeMail {
|
|
|
26
26
|
flagged?: boolean;
|
|
27
27
|
/** `trash` puts it in the trash folder; otherwise it is in All Mail, which INBOX also shows. */
|
|
28
28
|
folder?: "trash";
|
|
29
|
+
/** Folders that do not hold it although its `folder` says they do, as a server's delete from one folder leaves it. */
|
|
30
|
+
absentFrom?: string[];
|
|
29
31
|
}
|
|
30
32
|
/** The mailbox a fake source reads live, so a test changes it between syncs; the switches make the source fail. */
|
|
31
33
|
export interface FakeMailbox {
|
|
@@ -54,7 +54,7 @@ export const fakeMailSource = (mailbox) => {
|
|
|
54
54
|
const inFolder = (folder) => {
|
|
55
55
|
if (![all(), trash(), "INBOX"].includes(folder))
|
|
56
56
|
throw failure(`Gmail has no such mailbox: ${folder}`);
|
|
57
|
-
return mailbox.messages.filter((m) => (folder === trash() ? m.folder === "trash" : m.folder !== "trash"));
|
|
57
|
+
return mailbox.messages.filter((m) => (folder === trash() ? m.folder === "trash" : m.folder !== "trash") && !m.absentFrom?.includes(folder));
|
|
58
58
|
};
|
|
59
59
|
const search = async (since, folder) => {
|
|
60
60
|
if (mailbox.searchFails)
|
package/dist/store/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type { BotUpdate, BotUpdateStore } from "./sqlite/bot-updates.js";
|
|
|
29
29
|
export type { CatalogActor, CatalogEntry, CatalogInput, CatalogQuery, CatalogStore } from "./sqlite/catalog.js";
|
|
30
30
|
export { CATALOG_QUERY } from "./sqlite/catalog.js";
|
|
31
31
|
export type { ChatMetadata } from "./sqlite/chats.js";
|
|
32
|
-
export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
|
|
32
|
+
export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailLocationInput, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, FolderEmail, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
|
|
33
33
|
export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
|
|
34
34
|
export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
|
|
35
35
|
export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
|
|
@@ -92,6 +92,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
|
|
|
92
92
|
orm.delete(messages).where(eq(messages.accountId, accountKey)).run();
|
|
93
93
|
orm.delete(chats).where(eq(chats.accountId, accountKey)).run();
|
|
94
94
|
orm.delete(syncCursors).where(eq(syncCursors.accountId, accountKey)).run();
|
|
95
|
+
orm.run(sql `DELETE FROM email_locations WHERE mailbox_id IN (SELECT id FROM mailboxes WHERE account_id = ${accountKey})`);
|
|
95
96
|
// Deleting an email clears its recipients, mailboxes, words, attachments and tags (trigger email_bd).
|
|
96
97
|
orm.run(sql `DELETE FROM emails WHERE account_id = ${accountKey}`);
|
|
97
98
|
orm.run(sql `DELETE FROM taggings WHERE taggable_type = 'email_thread' AND taggable_id IN (SELECT id FROM email_threads WHERE account_id = ${accountKey})`);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CacheDatabase } from "../driver.js";
|
|
2
|
+
/** Where an IMAP server holds an email: `folder` is the mailbox's external id. */
|
|
3
|
+
export interface EmailLocationInput {
|
|
4
|
+
folder: string;
|
|
5
|
+
uidvalidity: string;
|
|
6
|
+
uid: number;
|
|
7
|
+
}
|
|
8
|
+
/** One email of a folder as a sync sees it there. */
|
|
9
|
+
export interface FolderEmail {
|
|
10
|
+
uid: number;
|
|
11
|
+
externalId: string;
|
|
12
|
+
receivedAt: number | null;
|
|
13
|
+
read: boolean | null;
|
|
14
|
+
flagged: boolean | null;
|
|
15
|
+
/** Its locations in every folder, and the folders it is a member of: an email is gone only when all are lost. */
|
|
16
|
+
locations: number;
|
|
17
|
+
folders: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const locate: (database: CacheDatabase, accountId: number, emailId: number, where: EmailLocationInput) => {
|
|
20
|
+
changes: number;
|
|
21
|
+
};
|
|
22
|
+
export declare const folderEmails: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string) => FolderEmail[];
|
|
23
|
+
export declare const setLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>) => number;
|
|
24
|
+
export declare const forgetLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, uids: number[]) => void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const locate = (database, accountId, emailId, where) => database
|
|
2
|
+
.prepare(`INSERT OR REPLACE INTO email_locations (mailbox_id, uidvalidity, uid, email_id)
|
|
3
|
+
SELECT id, ?, ?, ? FROM mailboxes WHERE account_id = ? AND external_id = ?`)
|
|
4
|
+
.run(where.uidvalidity, where.uid, emailId, accountId, where.folder);
|
|
5
|
+
export const folderEmails = (database, accountId, folder, uidvalidity) => database
|
|
6
|
+
.prepare(`SELECT l.uid, e.external_id, coalesce(e.received_at, e.sent_at) AS at, e.read, e.flagged,
|
|
7
|
+
(SELECT count(*) FROM email_locations x WHERE x.email_id = e.id) AS locations,
|
|
8
|
+
(SELECT count(*) FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
|
|
9
|
+
WHERE m.email_id = e.id AND b.kind IS NULL) AS folders
|
|
10
|
+
FROM email_locations l JOIN emails e ON e.id = l.email_id
|
|
11
|
+
WHERE l.mailbox_id = (SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?)
|
|
12
|
+
AND l.uidvalidity = ? AND e.deleted_at IS NULL
|
|
13
|
+
ORDER BY l.uid`)
|
|
14
|
+
.all(accountId, folder, uidvalidity)
|
|
15
|
+
.map((row) => ({
|
|
16
|
+
uid: Number(row.uid),
|
|
17
|
+
externalId: String(row.external_id),
|
|
18
|
+
receivedAt: row.at === null ? null : Number(row.at),
|
|
19
|
+
read: row.read === null ? null : Boolean(row.read),
|
|
20
|
+
flagged: row.flagged === null ? null : Boolean(row.flagged),
|
|
21
|
+
locations: Number(row.locations),
|
|
22
|
+
folders: Number(row.folders),
|
|
23
|
+
}));
|
|
24
|
+
export const setLocations = (database, accountId, folder, uidvalidity, found) => {
|
|
25
|
+
database
|
|
26
|
+
.prepare(`DELETE FROM email_locations WHERE uidvalidity <> ?
|
|
27
|
+
AND mailbox_id = (SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?)`)
|
|
28
|
+
.run(uidvalidity, accountId, folder);
|
|
29
|
+
const lookup = database.prepare("SELECT id FROM emails WHERE account_id = ? AND external_id = ?");
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const [externalId, uids] of found) {
|
|
32
|
+
const row = lookup.get(accountId, externalId);
|
|
33
|
+
if (!row)
|
|
34
|
+
continue;
|
|
35
|
+
for (const uid of uids)
|
|
36
|
+
locate(database, accountId, Number(row.id), { folder, uidvalidity, uid });
|
|
37
|
+
count++;
|
|
38
|
+
}
|
|
39
|
+
return count;
|
|
40
|
+
};
|
|
41
|
+
export const forgetLocations = (database, accountId, folder, uidvalidity, uids) => {
|
|
42
|
+
const box = database
|
|
43
|
+
.prepare("SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?")
|
|
44
|
+
.get(accountId, folder);
|
|
45
|
+
if (!box)
|
|
46
|
+
return;
|
|
47
|
+
const emails = database
|
|
48
|
+
.prepare(`DELETE FROM email_locations WHERE mailbox_id = ? AND uidvalidity = ?
|
|
49
|
+
AND uid IN (SELECT value FROM json_each(?)) RETURNING email_id`)
|
|
50
|
+
.all(Number(box.id), uidvalidity, JSON.stringify(uids))
|
|
51
|
+
.map((row) => Number(row.email_id));
|
|
52
|
+
const leave = database.prepare(`DELETE FROM email_mailboxes WHERE email_id = ? AND mailbox_id = ?
|
|
53
|
+
AND NOT EXISTS (SELECT 1 FROM email_locations WHERE email_id = ? AND mailbox_id = ?)`);
|
|
54
|
+
for (const emailId of new Set(emails))
|
|
55
|
+
leave.run(emailId, Number(box.id), emailId, Number(box.id));
|
|
56
|
+
};
|
|
@@ -6,3 +6,5 @@ export declare const setEmailFlags: (database: CacheDatabase, accountId: number,
|
|
|
6
6
|
read?: boolean;
|
|
7
7
|
flagged?: boolean;
|
|
8
8
|
}>, now: number) => number;
|
|
9
|
+
export declare const markEmailsDeleted: (database: CacheDatabase, accountId: number, externalIds: string[], now: number) => number;
|
|
10
|
+
export declare const markEmailsRestored: (database: CacheDatabase, accountId: number, externalIds: string[], now: number) => number;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { dropAttachmentChunks } from "./email-index.js";
|
|
1
2
|
import { flag } from "./events.js";
|
|
3
|
+
import { dropUnusedVectors } from "./vector-index.js";
|
|
2
4
|
export const recountEmailThread = (database, threadId, now) => database
|
|
3
5
|
.prepare(`UPDATE email_threads SET
|
|
4
6
|
emails_count = (SELECT count(*) FROM emails WHERE email_thread_id = ? AND deleted_at IS NULL),
|
|
@@ -23,3 +25,29 @@ export const setEmailFlags = (database, accountId, flags, now) => {
|
|
|
23
25
|
}
|
|
24
26
|
return changed;
|
|
25
27
|
};
|
|
28
|
+
export const markEmailsDeleted = (database, accountId, externalIds, now) => {
|
|
29
|
+
const mark = database.prepare(`UPDATE emails SET deleted_at = ?, body_text = NULL, body_html = NULL, snippet = NULL, updated_at = ?
|
|
30
|
+
WHERE account_id = ? AND external_id = ? AND deleted_at IS NULL
|
|
31
|
+
RETURNING id, email_thread_id`);
|
|
32
|
+
const forget = database.prepare("UPDATE attachments SET text = NULL, normalized_text = NULL WHERE attachable_type = 'email' AND attachable_id = ?");
|
|
33
|
+
const unlocate = database.prepare("DELETE FROM email_locations WHERE email_id = ?");
|
|
34
|
+
const threads = externalIds.flatMap((id) => mark.all(now, now, accountId, id).map((r) => {
|
|
35
|
+
const pieces = dropAttachmentChunks(database, Number(r.id));
|
|
36
|
+
forget.run(Number(r.id));
|
|
37
|
+
unlocate.run(Number(r.id));
|
|
38
|
+
dropUnusedVectors(database, pieces);
|
|
39
|
+
return Number(r.email_thread_id);
|
|
40
|
+
}));
|
|
41
|
+
for (const id of new Set(threads))
|
|
42
|
+
recountEmailThread(database, id, now);
|
|
43
|
+
return threads.length;
|
|
44
|
+
};
|
|
45
|
+
export const markEmailsRestored = (database, accountId, externalIds, now) => {
|
|
46
|
+
const unmark = database.prepare(`UPDATE emails SET deleted_at = NULL, updated_at = ?
|
|
47
|
+
WHERE account_id = ? AND external_id = ? AND deleted_at IS NOT NULL
|
|
48
|
+
RETURNING email_thread_id`);
|
|
49
|
+
const threads = externalIds.flatMap((id) => unmark.all(now, accountId, id).map((r) => Number(r.email_thread_id)));
|
|
50
|
+
for (const id of new Set(threads))
|
|
51
|
+
recountEmailThread(database, id, now);
|
|
52
|
+
return threads.length;
|
|
53
|
+
};
|
|
@@ -3,9 +3,11 @@ import type { Attachment, Metadata } from "../../meetings/index.js";
|
|
|
3
3
|
import type { Stemmer, Stemmers } from "../../search/stem.js";
|
|
4
4
|
import type { CacheDatabase } from "../driver.js";
|
|
5
5
|
import type { ConversationSummary, StoredHit } from "../types.js";
|
|
6
|
+
import { type EmailLocationInput, type FolderEmail } from "./email-locations.js";
|
|
6
7
|
import { type MailRulesStore } from "./mail-rules.js";
|
|
7
8
|
import { type AttachmentInput } from "./meetings.js";
|
|
8
9
|
import type { StoreContext } from "./open.js";
|
|
10
|
+
export type { EmailLocationInput, FolderEmail };
|
|
9
11
|
export interface EmailAddress {
|
|
10
12
|
address: string;
|
|
11
13
|
name: string | null;
|
|
@@ -43,6 +45,8 @@ export interface EmailInput {
|
|
|
43
45
|
mailRuleId?: number | null;
|
|
44
46
|
/** Added to the mailboxes the email is already in; one scan sees one folder, so none is taken away. */
|
|
45
47
|
mailboxes?: MailboxInput[];
|
|
48
|
+
/** Added to the locations the email has; a UID already known in that folder now points here. */
|
|
49
|
+
locations?: EmailLocationInput[];
|
|
46
50
|
/** Replaced when given; the text is word-indexed. */
|
|
47
51
|
attachments?: AttachmentInput[];
|
|
48
52
|
}
|
|
@@ -228,6 +232,15 @@ export interface MailStore {
|
|
|
228
232
|
read?: boolean;
|
|
229
233
|
flagged?: boolean;
|
|
230
234
|
}>, now: number): Promise<number>;
|
|
235
|
+
/** The live emails whose location in `folder` has this UIDVALIDITY, oldest UID first. */
|
|
236
|
+
folderEmails(accountId: number, folder: string, uidvalidity: string): Promise<FolderEmail[]>;
|
|
237
|
+
/**
|
|
238
|
+
* Records where emails the store holds are in `folder`, by Message-ID; one it does not hold is left out. Locations
|
|
239
|
+
* of the folder under another UIDVALIDITY are dropped: its UIDs mean nothing now.
|
|
240
|
+
*/
|
|
241
|
+
setLocations(accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>): Promise<number>;
|
|
242
|
+
/** Forgets that these UIDs of `folder` hold anything; an email left with no location there leaves the folder too. */
|
|
243
|
+
forgetLocations(accountId: number, folder: string, uidvalidity: string, uids: number[]): Promise<void>;
|
|
231
244
|
/** Marks emails gone at the source and drops their text; saving one again brings it back. Answers how many changed. */
|
|
232
245
|
markDeleted(accountId: number, externalIds: string[], now: number): Promise<number>;
|
|
233
246
|
/**
|
|
@@ -2,7 +2,8 @@ import { formatLocator } from "../../domain/locator.js";
|
|
|
2
2
|
import { formatReference } from "../../domain/references.js";
|
|
3
3
|
import { fold, normalize } from "../normalize.js";
|
|
4
4
|
import { chunkAttachments, drainEmailIndex, dropAttachmentChunks, MAIL_PIECES, queueThreads, threadPieces, titleOf, } from "./email-index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { folderEmails, forgetLocations, locate, setLocations, } from "./email-locations.js";
|
|
6
|
+
import { markEmailsDeleted, markEmailsRestored, recountEmailThread as recount, setEmailFlags } from "./email-state.js";
|
|
6
7
|
import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
|
|
7
8
|
import { folded } from "./folded.js";
|
|
8
9
|
import { ensurePerson } from "./identities.js";
|
|
@@ -228,6 +229,8 @@ const saveEmail = (database, orm, accountId, threadId, mail, now) => {
|
|
|
228
229
|
}
|
|
229
230
|
for (const box of mail.mailboxes ?? [])
|
|
230
231
|
join(database, emailId, mailboxPk(database, accountId, box, now), now);
|
|
232
|
+
for (const where of mail.locations ?? [])
|
|
233
|
+
locate(database, accountId, emailId, where);
|
|
231
234
|
if (mail.attachments !== undefined) {
|
|
232
235
|
const before = dropAttachmentChunks(database, emailId);
|
|
233
236
|
const positions = mail.attachments.map(({ position }) => position);
|
|
@@ -545,34 +548,11 @@ export const mailStoreOver = ({ database, orm }, stemmerFor = stemmerCache()) =>
|
|
|
545
548
|
return setEmailFlags(database, accountId, flags, now);
|
|
546
549
|
});
|
|
547
550
|
},
|
|
548
|
-
async
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const forget = database.prepare("UPDATE attachments SET text = NULL, normalized_text = NULL WHERE attachable_type = 'email' AND attachable_id = ?");
|
|
554
|
-
const threads = externalIds.flatMap((id) => mark.all(now, now, accountId, id).map((r) => {
|
|
555
|
-
const pieces = dropAttachmentChunks(database, Number(r.id));
|
|
556
|
-
forget.run(Number(r.id));
|
|
557
|
-
dropUnusedVectors(database, pieces);
|
|
558
|
-
return Number(r.email_thread_id);
|
|
559
|
-
}));
|
|
560
|
-
for (const id of new Set(threads))
|
|
561
|
-
recount(database, id, now);
|
|
562
|
-
return threads.length;
|
|
563
|
-
});
|
|
564
|
-
},
|
|
565
|
-
async markRestored(accountId, externalIds, now) {
|
|
566
|
-
return write(database, () => {
|
|
567
|
-
const unmark = database.prepare(`UPDATE emails SET deleted_at = NULL, updated_at = ?
|
|
568
|
-
WHERE account_id = ? AND external_id = ? AND deleted_at IS NOT NULL
|
|
569
|
-
RETURNING email_thread_id`);
|
|
570
|
-
const threads = externalIds.flatMap((id) => unmark.all(now, accountId, id).map((r) => Number(r.email_thread_id)));
|
|
571
|
-
for (const id of new Set(threads))
|
|
572
|
-
recount(database, id, now);
|
|
573
|
-
return threads.length;
|
|
574
|
-
});
|
|
575
|
-
},
|
|
551
|
+
folderEmails: async (accountId, folder, uidvalidity) => folderEmails(database, accountId, folder, uidvalidity),
|
|
552
|
+
setLocations: async (accountId, folder, uidvalidity, found) => write(database, () => setLocations(database, accountId, folder, uidvalidity, found)),
|
|
553
|
+
forgetLocations: async (accountId, folder, uidvalidity, uids) => write(database, () => forgetLocations(database, accountId, folder, uidvalidity, uids)),
|
|
554
|
+
markDeleted: async (accountId, externalIds, now) => write(database, () => markEmailsDeleted(database, accountId, externalIds, now)),
|
|
555
|
+
markRestored: async (accountId, externalIds, now) => write(database, () => markEmailsRestored(database, accountId, externalIds, now)),
|
|
576
556
|
async chunksToEmbed(model, { after, limit }) {
|
|
577
557
|
drainEmailIndex(database, stemmerFor);
|
|
578
558
|
const file = database.prepare("SELECT text FROM attachments WHERE id = ?");
|
|
@@ -52,6 +52,7 @@ export const MANIFEST = [
|
|
|
52
52
|
{ name: "20261029181000_version-37-contact-mention-triggers", version: 37, minCompatible: 34 },
|
|
53
53
|
{ name: "20261029224106_version-38-folded-names", version: 38, minCompatible: 34 },
|
|
54
54
|
{ name: "20261029224107_version-38-folded-triggers", version: 38, minCompatible: 34 },
|
|
55
|
+
{ name: "20261030114008_version-39-email-locations", version: 39, minCompatible: 34 },
|
|
55
56
|
];
|
|
56
57
|
export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
|
|
57
58
|
const migrations = [];
|
|
@@ -830,5 +830,12 @@ export const GENERATED = [
|
|
|
830
830
|
"CREATE TRIGGER email_threads_subject_folded_au AFTER UPDATE OF subject ON email_threads\n WHEN new.subject IS NOT old.subject AND new.subject_folded IS old.subject_folded BEGIN\n UPDATE email_threads SET subject_folded = NULL WHERE id = new.id;\nEND;",
|
|
831
831
|
"CREATE TRIGGER mailboxes_name_folded_au AFTER UPDATE OF name ON mailboxes\n WHEN new.name IS NOT old.name AND new.name_folded IS old.name_folded BEGIN\n UPDATE mailboxes SET name_folded = NULL WHERE id = new.id;\nEND;"
|
|
832
832
|
]
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
"name": "20261030114008_version-39-email-locations",
|
|
836
|
+
"statements": [
|
|
837
|
+
"CREATE TABLE `email_locations` (\n\t`mailbox_id` integer NOT NULL,\n\t`uidvalidity` text NOT NULL,\n\t`uid` integer NOT NULL,\n\t`email_id` integer NOT NULL,\n\tCONSTRAINT `email_locations_pk` PRIMARY KEY(`mailbox_id`, `uidvalidity`, `uid`),\n\tCONSTRAINT `fk_email_locations_mailbox_id_mailboxes_id_fk` FOREIGN KEY (`mailbox_id`) REFERENCES `mailboxes`(`id`),\n\tCONSTRAINT `fk_email_locations_email_id_emails_id_fk` FOREIGN KEY (`email_id`) REFERENCES `emails`(`id`)\n);",
|
|
838
|
+
"CREATE INDEX `email_locations_by_email_id` ON `email_locations` (`email_id`);"
|
|
839
|
+
]
|
|
833
840
|
}
|
|
834
841
|
];
|
|
@@ -6172,6 +6172,78 @@ export declare const emailMailboxes: import("drizzle-orm/sqlite-core").SQLiteTab
|
|
|
6172
6172
|
dialect: 'sqlite';
|
|
6173
6173
|
isAlias: false;
|
|
6174
6174
|
}>;
|
|
6175
|
+
export declare const emailLocations: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
6176
|
+
name: "email_locations";
|
|
6177
|
+
schema: undefined;
|
|
6178
|
+
columns: {
|
|
6179
|
+
mailboxId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6180
|
+
name: string;
|
|
6181
|
+
tableName: "email_locations";
|
|
6182
|
+
dataType: "number int53";
|
|
6183
|
+
data: number;
|
|
6184
|
+
driverParam: number;
|
|
6185
|
+
notNull: true;
|
|
6186
|
+
hasDefault: false;
|
|
6187
|
+
isPrimaryKey: false;
|
|
6188
|
+
isAutoincrement: false;
|
|
6189
|
+
hasRuntimeDefault: false;
|
|
6190
|
+
enumValues: undefined;
|
|
6191
|
+
baseColumn: never;
|
|
6192
|
+
identity: undefined;
|
|
6193
|
+
generated: undefined;
|
|
6194
|
+
}, {}>;
|
|
6195
|
+
uidvalidity: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6196
|
+
name: string;
|
|
6197
|
+
tableName: "email_locations";
|
|
6198
|
+
dataType: "string";
|
|
6199
|
+
data: string;
|
|
6200
|
+
driverParam: string;
|
|
6201
|
+
notNull: true;
|
|
6202
|
+
hasDefault: false;
|
|
6203
|
+
isPrimaryKey: false;
|
|
6204
|
+
isAutoincrement: false;
|
|
6205
|
+
hasRuntimeDefault: false;
|
|
6206
|
+
enumValues: [string, ...string[]];
|
|
6207
|
+
baseColumn: never;
|
|
6208
|
+
identity: undefined;
|
|
6209
|
+
generated: undefined;
|
|
6210
|
+
}, {}>;
|
|
6211
|
+
uid: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6212
|
+
name: string;
|
|
6213
|
+
tableName: "email_locations";
|
|
6214
|
+
dataType: "number int53";
|
|
6215
|
+
data: number;
|
|
6216
|
+
driverParam: number;
|
|
6217
|
+
notNull: true;
|
|
6218
|
+
hasDefault: false;
|
|
6219
|
+
isPrimaryKey: false;
|
|
6220
|
+
isAutoincrement: false;
|
|
6221
|
+
hasRuntimeDefault: false;
|
|
6222
|
+
enumValues: undefined;
|
|
6223
|
+
baseColumn: never;
|
|
6224
|
+
identity: undefined;
|
|
6225
|
+
generated: undefined;
|
|
6226
|
+
}, {}>;
|
|
6227
|
+
emailId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6228
|
+
name: string;
|
|
6229
|
+
tableName: "email_locations";
|
|
6230
|
+
dataType: "number int53";
|
|
6231
|
+
data: number;
|
|
6232
|
+
driverParam: number;
|
|
6233
|
+
notNull: true;
|
|
6234
|
+
hasDefault: false;
|
|
6235
|
+
isPrimaryKey: false;
|
|
6236
|
+
isAutoincrement: false;
|
|
6237
|
+
hasRuntimeDefault: false;
|
|
6238
|
+
enumValues: undefined;
|
|
6239
|
+
baseColumn: never;
|
|
6240
|
+
identity: undefined;
|
|
6241
|
+
generated: undefined;
|
|
6242
|
+
}, {}>;
|
|
6243
|
+
};
|
|
6244
|
+
dialect: 'sqlite';
|
|
6245
|
+
isAlias: false;
|
|
6246
|
+
}>;
|
|
6175
6247
|
export declare const emailIndexPending: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
6176
6248
|
name: "email_index_pending";
|
|
6177
6249
|
schema: undefined;
|
|
@@ -695,6 +695,19 @@ export const emailMailboxes = sqliteTable("email_mailboxes", {
|
|
|
695
695
|
primaryKey({ columns: [table.emailId, table.mailboxId] }),
|
|
696
696
|
index("email_mailboxes_by_mailbox_id").on(table.mailboxId),
|
|
697
697
|
]);
|
|
698
|
+
export const emailLocations = sqliteTable("email_locations", {
|
|
699
|
+
mailboxId: integer("mailbox_id")
|
|
700
|
+
.notNull()
|
|
701
|
+
.references(() => mailboxes.id),
|
|
702
|
+
uidvalidity: text("uidvalidity").notNull(),
|
|
703
|
+
uid: integer("uid").notNull(),
|
|
704
|
+
emailId: integer("email_id")
|
|
705
|
+
.notNull()
|
|
706
|
+
.references(() => emails.id),
|
|
707
|
+
}, (table) => [
|
|
708
|
+
primaryKey({ columns: [table.mailboxId, table.uidvalidity, table.uid] }),
|
|
709
|
+
index("email_locations_by_email_id").on(table.emailId),
|
|
710
|
+
]);
|
|
698
711
|
export const emailIndexPending = sqliteTable("email_index_pending", {
|
|
699
712
|
id: integer("id").notNull(),
|
|
700
713
|
indexableType: text("indexable_type").notNull(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.488.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|