@wirecat/cli-core 0.524.0 → 0.525.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 +20 -0
- package/dist/cli/messenger/search-backend-options.d.ts +3 -1
- package/dist/cli/messenger/search-backend-options.js +5 -3
- package/dist/cli/settings.js +3 -1
- package/dist/errors/reasons.js +7 -0
- package/dist/mail/cli/index.d.ts +3 -0
- package/dist/mail/cli/index.js +96 -19
- package/dist/mail/email-input.d.ts +5 -0
- package/dist/mail/email-input.js +16 -0
- package/dist/mail/index.d.ts +2 -0
- package/dist/mail/index.js +2 -0
- package/dist/mail/read.d.ts +6 -1
- package/dist/mail/read.js +2 -1
- package/dist/mail/server-query.d.ts +33 -0
- package/dist/mail/server-query.js +92 -0
- package/dist/mail/server-search.d.ts +33 -0
- package/dist/mail/server-search.js +215 -0
- package/dist/mail/source.d.ts +6 -0
- package/dist/mail/sync.js +3 -13
- package/dist/mail/testing/fake.d.ts +6 -0
- package/dist/mail/testing/fake.js +29 -0
- package/dist/ports/app.d.ts +2 -0
- package/dist/store/sqlite/lucene.js +4 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
|
|
|
5
5
|
minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
|
|
6
6
|
shape of this file.
|
|
7
7
|
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## 0.525.0 — 01.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Mail server search.** `mailReadCommands({ app, read, server })` gives `search messages` the shared
|
|
15
|
+
`--backend archive|server|both` and `--server-time`, as tg has them: the mail server is asked for candidates, the
|
|
16
|
+
new ones are stored, and the same strict query runs on the store, so each hit says `found_in`. `searchMailServer`
|
|
17
|
+
does it; `mailCriteriaOf` turns the query into `MailCriteria` (words, `from:` `to:` `cc:` `subject:` `body:`
|
|
18
|
+
`is:` and `date:`, one per top-level OR) for a `MailSource.query`, which a mail CLI translates into its server's
|
|
19
|
+
syntax. An owner's skip and envelope rules keep a find out of the store, as in a sync; nothing moves the sync's saved
|
|
20
|
+
position. The fake mail source answers `query` and records it in `queried`. When the server answers in part,
|
|
21
|
+
`meta.complete` is false with an issue per reason (`search.offline`, `search.server-timeout`, …; new
|
|
22
|
+
`search.server-*` reasons).
|
|
23
|
+
- `searchMail` takes `only`, and the store's word search reads `only` over mail as over messages.
|
|
24
|
+
- `serverSearchOptions(command, where)` mounts `--backend` and `--server-time` on any search; `backendOptions` uses it.
|
|
25
|
+
- **`AppIdentity.sends`**: a CLI that is not a messenger yet sends to people (mail) keeps `sends_per_hour`, so the
|
|
26
|
+
owner can set its hourly limit with `config set sends_per_hour`; the other messenger settings stay hidden.
|
|
27
|
+
|
|
8
28
|
## 0.524.0 — 01.11.2026
|
|
9
29
|
|
|
10
30
|
### Added
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import type { Backend, ServerOptions, ServerSearched } from "../../services/server-search.js";
|
|
3
3
|
import type { Messenger, MessengerContext } from "./context.js";
|
|
4
|
+
/** `--backend` and `--server-time`, for any search whose source has a server that searches: `where` names it. */
|
|
5
|
+
export declare const serverSearchOptions: (command: Command, where: string) => Command;
|
|
4
6
|
/** Mounted only where the messenger's server can search messages. */
|
|
5
7
|
export declare const backendOptions: (command: Command, messenger: Messenger) => Command;
|
|
6
8
|
export declare const backendRequest: (command: Command) => {
|
|
7
9
|
backend?: Backend;
|
|
8
10
|
server?: ServerOptions;
|
|
9
11
|
};
|
|
10
|
-
export declare const noteServer: (context: MessengerContext, server: ServerSearched | undefined) => void;
|
|
12
|
+
export declare const noteServer: (context: Pick<MessengerContext, "renderer">, server: ServerSearched | undefined) => void;
|
|
@@ -5,11 +5,13 @@ const backendOf = (value) => {
|
|
|
5
5
|
throw new CliError("validation_error", "--backend takes archive, server or both");
|
|
6
6
|
return value;
|
|
7
7
|
};
|
|
8
|
+
/** `--backend` and `--server-time`, for any search whose source has a server that searches: `where` names it. */
|
|
9
|
+
export const serverSearchOptions = (command, where) => command
|
|
10
|
+
.option("--backend <archive|server|both>", `where to search: the local archive, ${where}, or both`, backendOf)
|
|
11
|
+
.option("--server-time <duration>", "stop waiting for the server after this long (default: 5s)");
|
|
8
12
|
/** Mounted only where the messenger's server can search messages. */
|
|
9
13
|
export const backendOptions = (command, messenger) => messenger.serverSearch
|
|
10
|
-
? command
|
|
11
|
-
.option("--backend <archive|server|both>", "where to search: the local archive, the messenger's server, or both (default: both; message discovery uses archive only)", backendOf)
|
|
12
|
-
.option("--server-time <duration>", "stop waiting for the server after this long (default: 5s)")
|
|
14
|
+
? serverSearchOptions(command, "the messenger's server (default: both; message discovery uses archive only)")
|
|
13
15
|
: command;
|
|
14
16
|
export const backendRequest = (command) => {
|
|
15
17
|
const { backend, serverTime } = command.opts();
|
package/dist/cli/settings.js
CHANGED
|
@@ -162,7 +162,9 @@ export const settingsFor = (app, extension = {}) => {
|
|
|
162
162
|
const schema = strictOf(configEntries);
|
|
163
163
|
const defaultsOnlyKeys = Object.keys(defaultsOnly);
|
|
164
164
|
const botOnlyKeys = Object.keys(BOT_ONLY_ENTRIES);
|
|
165
|
-
const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys].filter((setting) => app.messenger !== false ||
|
|
165
|
+
const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys].filter((setting) => app.messenger !== false ||
|
|
166
|
+
!MESSENGER_ONLY_SETTINGS.includes(setting) ||
|
|
167
|
+
(app.sends === true && setting === "sends_per_hour"));
|
|
166
168
|
const PROFILE = envName(app, "PROFILE");
|
|
167
169
|
const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
|
|
168
170
|
const TIMEOUT = envName(app, "TIMEOUT");
|
package/dist/errors/reasons.js
CHANGED
|
@@ -69,6 +69,13 @@ const OTHERS = [
|
|
|
69
69
|
validation("search.no-calendars", "no calendar of this profile syncs yet, so the provider has nothing to search"),
|
|
70
70
|
validation("search.server-options", "search --backend server takes neither --exact, --cursor nor --newest"),
|
|
71
71
|
validation("search.one-backend", "search answers from one backend at a time: archive or server"),
|
|
72
|
+
["search.server-not-allowed", "permission_error", "the profile does not allow messages.server-search"],
|
|
73
|
+
validation("search.server-no-criteria", "the query has nothing the server can search, so only the store is read"),
|
|
74
|
+
["search.server-unsupported", "provider_error", "this source's server cannot search"],
|
|
75
|
+
["search.server-rate-limited", "rate_limited", "the server refused a search as too many requests"],
|
|
76
|
+
["search.server-failed", "provider_unavailable", "the server did not answer a search"],
|
|
77
|
+
["search.server-timeout", "timeout", "the server did not answer within --server-time"],
|
|
78
|
+
["search.server-capped", "outcome_unknown", "the server found more than one search reads, or a branch was not sent"],
|
|
72
79
|
validation("store.refused", "the local store refused the document text"),
|
|
73
80
|
["store.not-writable", "permission_error", "the message store cannot be written from this process"],
|
|
74
81
|
[
|
package/dist/mail/cli/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { type MailServerAccess } from "../../mail/server-search.js";
|
|
2
3
|
import type { AppIdentity } from "../../ports/app.js";
|
|
3
4
|
import { type AccountKey, type MessageStore } from "../../store/store.js";
|
|
4
5
|
/** What a mail CLI hands the shared commands: how to open the store for one run, and whose mail it reads. */
|
|
@@ -9,6 +10,8 @@ export interface MailReads {
|
|
|
9
10
|
* reads that mailbox alone; the store must take writes, since a search tops up its word index first.
|
|
10
11
|
*/
|
|
11
12
|
read: <T>(command: Command, work: (store: MessageStore, mailbox: AccountKey) => Promise<T>) => Promise<T>;
|
|
13
|
+
/** The mailbox's server, for `search messages --backend`; without it the search reads the store only. */
|
|
14
|
+
server?: (command: Command) => MailServerAccess;
|
|
12
15
|
}
|
|
13
16
|
export declare const THREAD_QUERY: {
|
|
14
17
|
readonly entity: "mail_thread";
|
package/dist/mail/cli/index.js
CHANGED
|
@@ -5,15 +5,18 @@ import { Command, InvalidArgumentError } from "commander";
|
|
|
5
5
|
import * as v from "valibot";
|
|
6
6
|
import { answerEntity } from "../../cli/answer-entity.js";
|
|
7
7
|
import { outputFor } from "../../cli/context.js";
|
|
8
|
-
import { LOCAL_READ } from "../../cli/effects.js";
|
|
8
|
+
import { LOCAL_READ, REMOTE_READ } from "../../cli/effects.js";
|
|
9
9
|
import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
|
|
10
10
|
import { listQuery, typedQuery, withQuery } from "../../cli/list-query.js";
|
|
11
11
|
import { messagesAnswer, offsetOf, shownOf } from "../../cli/messenger/search-answers.js";
|
|
12
|
+
import { backendRequest, noteServer, serverSearchOptions } from "../../cli/messenger/search-backend-options.js";
|
|
12
13
|
import { positiveCount } from "../../cli/paging.js";
|
|
13
14
|
import { formatReference, parseReference } from "../../domain/references.js";
|
|
14
15
|
import { count, reference, text, time, untrusted } from "../../entities/fields.js";
|
|
15
16
|
import { attachmentsText, hitText, searchMail, showMail, withAttachmentText } from "../../mail/read.js";
|
|
17
|
+
import { searchMailServer } from "../../mail/server-search.js";
|
|
16
18
|
import { atDetail } from "../../output-detail.js";
|
|
19
|
+
import { sourceKey } from "../../services/server-search.js";
|
|
17
20
|
import { queryFilter, THREAD_COLUMNS } from "../../store/store.js";
|
|
18
21
|
const DEFAULT_LIMIT = 20;
|
|
19
22
|
const MAX_LIMIT = 100;
|
|
@@ -41,9 +44,37 @@ const threadOf = (mailbox, given) => {
|
|
|
41
44
|
throw new CliError("not_found", `${given} is not mail of this profile's mailbox ${mailbox.account}`);
|
|
42
45
|
return place.chat;
|
|
43
46
|
};
|
|
47
|
+
const SKIPPED = {
|
|
48
|
+
unsupported: { code: "provider_error", reason: "search.server-unsupported" },
|
|
49
|
+
pushed_history: { code: "provider_error", reason: "search.server-unsupported" },
|
|
50
|
+
offline: { code: "permission_error", reason: "search.offline" },
|
|
51
|
+
not_allowed: { code: "permission_error", reason: "search.server-not-allowed" },
|
|
52
|
+
other_accounts: { code: "validation_error", reason: "search.server-no-criteria" },
|
|
53
|
+
no_words: { code: "validation_error", reason: "search.server-no-criteria" },
|
|
54
|
+
needs_chat: { code: "validation_error", reason: "search.server-no-criteria" },
|
|
55
|
+
};
|
|
56
|
+
const FAILED = {
|
|
57
|
+
rate_limited: { code: "rate_limited", reason: "search.server-rate-limited" },
|
|
58
|
+
search_failed: { code: "provider_unavailable", reason: "search.server-failed" },
|
|
59
|
+
time_or_abort_bound: { code: "timeout", reason: "search.server-timeout" },
|
|
60
|
+
};
|
|
61
|
+
/** What the server search left out, so `meta.complete` is false whenever any of it is (WC-OUT-7). */
|
|
62
|
+
const serverIssues = (server) => {
|
|
63
|
+
if (!server || server.complete)
|
|
64
|
+
return [];
|
|
65
|
+
if (server.skipped)
|
|
66
|
+
return [{ ...SKIPPED[server.skipped], source: "server" }];
|
|
67
|
+
const failed = [...new Set(server.failed.map(({ reason }) => reason))].map((reason) => ({
|
|
68
|
+
...FAILED[reason],
|
|
69
|
+
source: "server",
|
|
70
|
+
}));
|
|
71
|
+
return failed.length
|
|
72
|
+
? failed
|
|
73
|
+
: [{ code: "outcome_unknown", reason: "search.server-capped", source: "server" }];
|
|
74
|
+
};
|
|
44
75
|
const searchCommand = (mail) => {
|
|
45
76
|
const leaf = annotate(new Command("messages"), {
|
|
46
|
-
effects: LOCAL_READ,
|
|
77
|
+
effects: mail.server ? REMOTE_READ : LOCAL_READ,
|
|
47
78
|
partial: true,
|
|
48
79
|
output: { entity: "message", view: "hit" },
|
|
49
80
|
examples: [
|
|
@@ -52,8 +83,11 @@ const searchCommand = (mail) => {
|
|
|
52
83
|
`${mail.app.command} ya search messages 'list:github.com is:unread' --newest`,
|
|
53
84
|
],
|
|
54
85
|
})
|
|
55
|
-
.summary("find stored emails by words and fields")
|
|
56
|
-
.description(
|
|
86
|
+
.summary(mail.server ? "find emails by words and fields" : "find stored emails by words and fields")
|
|
87
|
+
.description(mail.server
|
|
88
|
+
? "search this profile's mail in the local store and, by default, on the mail server for mail sync has not read " +
|
|
89
|
+
"yet (--backend); the server's finds are stored, then one strict query decides; best match first"
|
|
90
|
+
: "search this profile's mail in the local store — what sync read — by words, people, folders, lists and dates; best match first")
|
|
57
91
|
.argument("<query...>", 'strict Lucene query: words, "phrases", AND/OR/NOT, field:value and date ranges')
|
|
58
92
|
.option("--limit <n>", `how many, at most ${MAX_LIMIT}`, limitOf)
|
|
59
93
|
.option("--page <n>", "which page, starting at 1", positiveCount("--page"))
|
|
@@ -66,23 +100,59 @@ Fields: from: to: cc: bcc: subject: body: mailbox: (or label:) list: (a List-Id
|
|
|
66
100
|
has:attachment, filename:, content: (the text of attachments), size>, date: — \`${mail.app.command} ya search messages 'content:invoice'\`.
|
|
67
101
|
chat: names a thread by its subject. Mail sent to many is in the mailbox bulk; NOT mailbox:bulk leaves it out.
|
|
68
102
|
Mail from senders with signs of fraud comes after the rest of the page; --plain-order keeps it in place.
|
|
69
|
-
--json: the list is items; each hit's reference is ref (msg:email/…), which messages show reads.
|
|
70
|
-
|
|
71
|
-
|
|
103
|
+
--json: the list is items; each hit's reference is ref (msg:email/…), which messages show reads.${mail.server
|
|
104
|
+
? `
|
|
105
|
+
--backend: the server is asked for words, from: to: cc: subject: body: is: and date:, in INBOX, Sent and Archive or
|
|
106
|
+
all of Gmail; the rest stays with the strict query on the store. Permission key: messages.server-search.
|
|
107
|
+
found_in says archive, server (new from the server) or both; meta.complete is false when the server answered in part.`
|
|
108
|
+
: ""}
|
|
109
|
+
Search guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md`);
|
|
110
|
+
if (mail.server)
|
|
111
|
+
serverSearchOptions(leaf, "the mail server (default: both)");
|
|
112
|
+
leaf.action(async function (words) {
|
|
72
113
|
const options = this.opts();
|
|
73
114
|
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
74
115
|
const page = options.page ?? 1;
|
|
75
116
|
const query = words.join(" ");
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
const { backend, server: bounds } = mail.server ? backendRequest(this) : {};
|
|
118
|
+
const found = await mail.read(this, async (store, mailbox) => {
|
|
119
|
+
const step = mail.server
|
|
120
|
+
? await searchMailServer(store, mailbox, mail.server(this), {
|
|
121
|
+
text: query,
|
|
122
|
+
...(backend === undefined ? {} : { backend }),
|
|
123
|
+
...(bounds?.timeMs === undefined ? {} : { timeMs: bounds.timeMs }),
|
|
124
|
+
...(options.exact ? { exact: true } : {}),
|
|
125
|
+
...(options.timezone === undefined ? {} : { timezone: options.timezone }),
|
|
126
|
+
})
|
|
127
|
+
: undefined;
|
|
128
|
+
const only = backend === "server" && step
|
|
129
|
+
? [...step.sources.keys()].map((key) => {
|
|
130
|
+
const [chatId, id] = JSON.parse(key);
|
|
131
|
+
return { ...mailbox, chatId, id };
|
|
132
|
+
})
|
|
133
|
+
: undefined;
|
|
134
|
+
const found = await searchMail(store, mailbox, {
|
|
135
|
+
text: query,
|
|
136
|
+
limit,
|
|
137
|
+
mailAccount: mailbox.account,
|
|
138
|
+
...(offsetOf(page, limit) === undefined ? {} : { offset: offsetOf(page, limit) }),
|
|
139
|
+
...(options.newest ? { newest: true } : {}),
|
|
140
|
+
...(options.exact ? { exact: true } : {}),
|
|
141
|
+
...(options.plainOrder ? { plainOrder: true } : {}),
|
|
142
|
+
...(options.timezone === undefined ? {} : { timezone: options.timezone }),
|
|
143
|
+
...(only ? { only } : {}),
|
|
144
|
+
}, { app: mail.app });
|
|
145
|
+
if (!step)
|
|
146
|
+
return found;
|
|
147
|
+
return {
|
|
148
|
+
...found,
|
|
149
|
+
server: step.report,
|
|
150
|
+
items: found.items.map((hit) => ({
|
|
151
|
+
...hit,
|
|
152
|
+
source: step.sources.get(sourceKey(hit.chatId, hit.id)) ?? "archive",
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
});
|
|
86
156
|
const output = outputFor(this);
|
|
87
157
|
const again = {
|
|
88
158
|
query,
|
|
@@ -92,11 +162,18 @@ Search guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/search/quer
|
|
|
92
162
|
...(options.timezone === undefined ? {} : { timezone: options.timezone }),
|
|
93
163
|
};
|
|
94
164
|
const level = detailOf(this);
|
|
95
|
-
|
|
165
|
+
const answer = messagesAnswer("search messages", found, { limit, page, again }, query);
|
|
166
|
+
const issues = serverIssues(found.server);
|
|
167
|
+
if (issues.length)
|
|
168
|
+
answer.meta = { ...answer.meta, complete: false, issues: [...(answer.meta?.issues ?? []), ...issues] };
|
|
169
|
+
renderAnswer(this, output, answer, () => {
|
|
96
170
|
if (!found.wordsReady)
|
|
97
171
|
output.renderer.note(`the word index is still being built — \`${mail.app.command} store migrate\` finishes it`);
|
|
172
|
+
noteServer(output, found.server);
|
|
98
173
|
if (found.items.length === 0)
|
|
99
|
-
output.renderer.note(
|
|
174
|
+
output.renderer.note(found.server && !found.server.skipped
|
|
175
|
+
? "nothing found in the local store or on the mail server"
|
|
176
|
+
: "nothing found — only mail sync stored is searched");
|
|
100
177
|
const shown = shownOf(found.items.length, (page - 1) * limit, found.total);
|
|
101
178
|
if (shown)
|
|
102
179
|
output.renderer.note(shown);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { EmailInput } from "../store/index.js";
|
|
2
|
+
import type { ParsedMail } from "./parse.js";
|
|
3
|
+
import type { Listed } from "./source.js";
|
|
4
|
+
/** What one read email stores of itself, whichever run read it; where it is filed is the caller's. */
|
|
5
|
+
export declare const parsedEmail: (mail: ParsedMail, item: Listed, header: Record<string, string | boolean>, address: string) => Omit<EmailInput, "mailboxes">;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { addressOf } from "./attachments.js";
|
|
2
|
+
/** What one read email stores of itself, whichever run read it; where it is filed is the caller's. */
|
|
3
|
+
export const parsedEmail = (mail, item, header, address) => ({
|
|
4
|
+
externalId: item.messageId,
|
|
5
|
+
subject: mail.subject,
|
|
6
|
+
from: mail.from === null ? null : addressOf(mail.from),
|
|
7
|
+
to: mail.to.map(addressOf),
|
|
8
|
+
cc: mail.cc.map(addressOf),
|
|
9
|
+
bcc: mail.bcc.map(addressOf),
|
|
10
|
+
sentAt: null,
|
|
11
|
+
receivedAt: Date.parse(item.receivedAt),
|
|
12
|
+
bodyText: mail.text || null,
|
|
13
|
+
size: item.size ?? null,
|
|
14
|
+
...(Object.keys(header).length ? { headers: header } : {}),
|
|
15
|
+
outgoing: mail.from === null ? null : mail.from.email === address,
|
|
16
|
+
});
|
package/dist/mail/index.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { mapProposedTags, ruleTagger, settleRuleTags, type TagOutcome } from "./
|
|
|
16
16
|
export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, type MailAction, type MailFacts, type MailRule, type MailRuleMatch, matches, sameMatch, tagsOf, } from "./rules.js";
|
|
17
17
|
export { type DomainAge, FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, type Scored, type Signal, scoreGroup, WEIGHTS, } from "./score.js";
|
|
18
18
|
export { DEMOTE_AT, type DomainAges, demotedSenders, type LookupResult, SCORE_VERSION, scoreSenders, } from "./sender-scores.js";
|
|
19
|
+
export { type MailCriteria, mailCriteriaOf, narrows } from "./server-query.js";
|
|
20
|
+
export { type MailServerAccess, type MailServerRequest, searchMailServer } from "./server-search.js";
|
|
19
21
|
export type { Listed, MailSource, Quick, Searched } from "./source.js";
|
|
20
22
|
export { listedOf, type StoredMail } from "./stored.js";
|
|
21
23
|
export { MAX_ATTACHMENT_CHARS, type MailAccount, type MailIndexed, type MailSyncOptions, type MailSyncResult, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.js";
|
package/dist/mail/index.js
CHANGED
|
@@ -16,5 +16,7 @@ export { mapProposedTags, ruleTagger, settleRuleTags } from "./rule-tags.js";
|
|
|
16
16
|
export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, matches, sameMatch, tagsOf, } from "./rules.js";
|
|
17
17
|
export { FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, scoreGroup, WEIGHTS, } from "./score.js";
|
|
18
18
|
export { DEMOTE_AT, demotedSenders, SCORE_VERSION, scoreSenders, } from "./sender-scores.js";
|
|
19
|
+
export { mailCriteriaOf, narrows } from "./server-query.js";
|
|
20
|
+
export { searchMailServer } from "./server-search.js";
|
|
19
21
|
export { listedOf } from "./stored.js";
|
|
20
22
|
export { MAX_ATTACHMENT_CHARS, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.js";
|
package/dist/mail/read.d.ts
CHANGED
|
@@ -16,12 +16,17 @@ export interface MailSearch {
|
|
|
16
16
|
newest?: boolean;
|
|
17
17
|
/** Leave mail from suspicious senders where the words put it, instead of after the rest. */
|
|
18
18
|
plainOrder?: boolean;
|
|
19
|
+
/** Only these emails, by thread and email id: `--backend server` reads what the server returned. */
|
|
20
|
+
only?: (AccountKey & {
|
|
21
|
+
chatId: string;
|
|
22
|
+
id: string;
|
|
23
|
+
})[];
|
|
19
24
|
}
|
|
20
25
|
/**
|
|
21
26
|
* Stored mail by the shared query language, best first. Mail whose sender search demotes comes after the rest,
|
|
22
27
|
* unless `plainOrder` or `newest` asks for another order. `runAs` is the account the search runs as.
|
|
23
28
|
*/
|
|
24
|
-
export declare const searchMail: (store: MessageStore, runAs: AccountKey, { text, limit, offset, accounts, mailAccount, exact, timezone, newest, plainOrder }: MailSearch, saved?: Saved) => Promise<SearchFound>;
|
|
29
|
+
export declare const searchMail: (store: MessageStore, runAs: AccountKey, { text, limit, offset, accounts, mailAccount, exact, timezone, newest, plainOrder, only }: MailSearch, saved?: Saved) => Promise<SearchFound>;
|
|
25
30
|
/** Enough for a bill's amounts and dates; the whole text is asked for separately. */
|
|
26
31
|
export declare const ATTACHMENT_CHARS = 4000;
|
|
27
32
|
export type ShownMail = StoredHit & {
|
package/dist/mail/read.js
CHANGED
|
@@ -12,7 +12,7 @@ const LOWERED_SPARE = 20;
|
|
|
12
12
|
* Stored mail by the shared query language, best first. Mail whose sender search demotes comes after the rest,
|
|
13
13
|
* unless `plainOrder` or `newest` asks for another order. `runAs` is the account the search runs as.
|
|
14
14
|
*/
|
|
15
|
-
export const searchMail = async (store, runAs, { text, limit, offset = 0, accounts, mailAccount, exact, timezone, newest, plainOrder }, saved = {}) => {
|
|
15
|
+
export const searchMail = async (store, runAs, { text, limit, offset = 0, accounts, mailAccount, exact, timezone, newest, plainOrder, only }, saved = {}) => {
|
|
16
16
|
const lower = !plainOrder && !newest;
|
|
17
17
|
const request = {
|
|
18
18
|
text,
|
|
@@ -22,6 +22,7 @@ export const searchMail = async (store, runAs, { text, limit, offset = 0, accoun
|
|
|
22
22
|
...(exact ? { exact: true } : {}),
|
|
23
23
|
...(timezone === undefined ? {} : { timezone }),
|
|
24
24
|
...(newest ? { newest: true } : {}),
|
|
25
|
+
...(only === undefined ? {} : { only }),
|
|
25
26
|
};
|
|
26
27
|
if (!lower)
|
|
27
28
|
return searchStore(store, runAs, { ...request, limit, offset }, saved);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a mail server is asked, in no server's syntax: every part must hold. Each list is AND-ed; a part the server
|
|
3
|
+
* cannot read is left out, which only widens the candidates, since the strict query on the store decides.
|
|
4
|
+
*/
|
|
5
|
+
export interface MailCriteria {
|
|
6
|
+
/** Words and phrases anywhere in the email. */
|
|
7
|
+
words: string[];
|
|
8
|
+
from: string[];
|
|
9
|
+
to: string[];
|
|
10
|
+
cc: string[];
|
|
11
|
+
subject: string[];
|
|
12
|
+
body: string[];
|
|
13
|
+
/** Received at or after this time, in ms. */
|
|
14
|
+
since?: number;
|
|
15
|
+
/** Received before this time, in ms. */
|
|
16
|
+
before?: number;
|
|
17
|
+
seen?: boolean;
|
|
18
|
+
flagged?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Whether the criteria narrow at all: a server asked for everything would only return the newest mail. */
|
|
21
|
+
export declare const narrows: (criteria: MailCriteria) => boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The shared query as mail server searches, one per top-level OR branch. Negations, patterns, `has:`, `list:`,
|
|
24
|
+
* `mailbox:` and the rest stay with the strict query on the store. A branch that narrows nothing is not sent, since a
|
|
25
|
+
* server asked for everything would only answer the newest mail; `partial` says one was left out.
|
|
26
|
+
*/
|
|
27
|
+
export declare const mailCriteriaOf: (text: string, { exact, timezone }?: {
|
|
28
|
+
exact?: boolean;
|
|
29
|
+
timezone?: string;
|
|
30
|
+
}) => {
|
|
31
|
+
branches: MailCriteria[];
|
|
32
|
+
partial: boolean;
|
|
33
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { dateRange, parseLucene, timezoneOf } from "@wirecat/cli-base/query";
|
|
2
|
+
import { validateFields } from "../search/lucene/registry.js";
|
|
3
|
+
import { MESSAGE_PARSING } from "../search/lucene/types.js";
|
|
4
|
+
const DAY = 86_400_000;
|
|
5
|
+
const empty = () => ({ words: [], from: [], to: [], cc: [], subject: [], body: [] });
|
|
6
|
+
/** Whether the criteria narrow at all: a server asked for everything would only return the newest mail. */
|
|
7
|
+
export const narrows = (criteria) => criteria.words.length +
|
|
8
|
+
criteria.from.length +
|
|
9
|
+
criteria.to.length +
|
|
10
|
+
criteria.cc.length +
|
|
11
|
+
criteria.subject.length +
|
|
12
|
+
criteria.body.length >
|
|
13
|
+
0 ||
|
|
14
|
+
criteria.since !== undefined ||
|
|
15
|
+
criteria.before !== undefined ||
|
|
16
|
+
criteria.seen !== undefined ||
|
|
17
|
+
criteria.flagged !== undefined;
|
|
18
|
+
const merge = (one, other) => {
|
|
19
|
+
if (one.seen !== undefined && other.seen !== undefined && one.seen !== other.seen)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (one.flagged !== undefined && other.flagged !== undefined && one.flagged !== other.flagged)
|
|
22
|
+
return undefined;
|
|
23
|
+
const since = Math.max(one.since ?? -Infinity, other.since ?? -Infinity);
|
|
24
|
+
const before = Math.min(one.before ?? Infinity, other.before ?? Infinity);
|
|
25
|
+
const seen = one.seen ?? other.seen;
|
|
26
|
+
const flagged = one.flagged ?? other.flagged;
|
|
27
|
+
return {
|
|
28
|
+
words: [...one.words, ...other.words],
|
|
29
|
+
from: [...one.from, ...other.from],
|
|
30
|
+
to: [...one.to, ...other.to],
|
|
31
|
+
cc: [...one.cc, ...other.cc],
|
|
32
|
+
subject: [...one.subject, ...other.subject],
|
|
33
|
+
body: [...one.body, ...other.body],
|
|
34
|
+
...(Number.isFinite(since) ? { since } : {}),
|
|
35
|
+
...(Number.isFinite(before) ? { before } : {}),
|
|
36
|
+
...(seen === undefined ? {} : { seen }),
|
|
37
|
+
...(flagged === undefined ? {} : { flagged }),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
const TEXT_FIELDS = { from: "from", to: "to", cc: "cc", subject: "subject", body: "body" };
|
|
41
|
+
const leafOf = (node, timezone) => {
|
|
42
|
+
const criteria = empty();
|
|
43
|
+
const plain = node.operator === "term" || node.operator === "phrase";
|
|
44
|
+
if ((node.field === "text" || node.field === "exact") && plain && node.value !== "")
|
|
45
|
+
return { ...criteria, words: [node.value] };
|
|
46
|
+
const field = TEXT_FIELDS[node.field];
|
|
47
|
+
// `from:me` is the mailbox's own address, which the store knows and the server's FROM does not.
|
|
48
|
+
if (field && plain && node.value !== "" && !(field === "from" && node.value.toLowerCase() === "me"))
|
|
49
|
+
return { ...criteria, [field]: [node.value] };
|
|
50
|
+
if (node.field === "is" && node.operator === "term") {
|
|
51
|
+
const state = node.value.toLowerCase();
|
|
52
|
+
if (state === "unread")
|
|
53
|
+
return { ...criteria, seen: false };
|
|
54
|
+
if (state === "read")
|
|
55
|
+
return { ...criteria, seen: true };
|
|
56
|
+
if (state === "starred")
|
|
57
|
+
return { ...criteria, flagged: true };
|
|
58
|
+
}
|
|
59
|
+
if (node.field === "date") {
|
|
60
|
+
const range = dateRange(node.value, node.operator === "range" ? (node.upper ?? "*") : node.value, node.operator === "range" ? node.lower_inclusive === true : true, node.operator === "range" ? node.upper_inclusive === true : true, timezone, node.span);
|
|
61
|
+
// A server reads whole days in its own zone: a day more each side, and the store cuts to the exact moment.
|
|
62
|
+
return {
|
|
63
|
+
...criteria,
|
|
64
|
+
...(range.lower === undefined ? {} : { since: range.lower - DAY }),
|
|
65
|
+
...(range.upper === undefined ? {} : { before: range.upper + DAY }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return criteria;
|
|
69
|
+
};
|
|
70
|
+
const branchesOf = (node, timezone) => {
|
|
71
|
+
if (node.kind === "predicate")
|
|
72
|
+
return [leafOf(node, timezone)];
|
|
73
|
+
const must = node.clauses.filter(({ occur }) => occur === "must");
|
|
74
|
+
if (must.length)
|
|
75
|
+
return must.reduce((product, { node }) => product.flatMap((one) => branchesOf(node, timezone).flatMap((other) => merge(one, other) ?? [])), [empty()]);
|
|
76
|
+
const should = node.clauses.filter(({ occur }) => occur === "should");
|
|
77
|
+
return should.length ? should.flatMap(({ node }) => branchesOf(node, timezone)) : [empty()];
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The shared query as mail server searches, one per top-level OR branch. Negations, patterns, `has:`, `list:`,
|
|
81
|
+
* `mailbox:` and the rest stay with the strict query on the store. A branch that narrows nothing is not sent, since a
|
|
82
|
+
* server asked for everything would only answer the newest mail; `partial` says one was left out.
|
|
83
|
+
*/
|
|
84
|
+
export const mailCriteriaOf = (text, { exact, timezone } = {}) => {
|
|
85
|
+
const ast = validateFields(parseLucene(text, { ...MESSAGE_PARSING, defaultField: exact ? "exact" : "text" }));
|
|
86
|
+
const all = branchesOf(ast.root, timezoneOf(timezone));
|
|
87
|
+
const sent = all.filter(narrows);
|
|
88
|
+
return {
|
|
89
|
+
branches: [...new Map(sent.map((branch) => [JSON.stringify(branch), branch])).values()],
|
|
90
|
+
partial: sent.length < all.length,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Level, type PermissionKey } from "../sends/permissions.js";
|
|
2
|
+
import { type Backend, type ServerStep } from "../services/server-search.js";
|
|
3
|
+
import type { AccountKey, MessageStore } from "../store/index.js";
|
|
4
|
+
import type { MailSource } from "./source.js";
|
|
5
|
+
/** What a run lets the server search do, read from the profile before anything is opened. */
|
|
6
|
+
export interface MailServerAccess {
|
|
7
|
+
offline: boolean;
|
|
8
|
+
permissions: Readonly<Record<PermissionKey, Level>>;
|
|
9
|
+
defaults?: Readonly<Record<PermissionKey, Level>>;
|
|
10
|
+
/** `<PREFIX>_READ_ONLY`: the search writes what the server finds into the store, so it stays the archive's. */
|
|
11
|
+
readOnly?: boolean;
|
|
12
|
+
/** The mailbox and the folders a search reads, closed after the search. */
|
|
13
|
+
open: () => Promise<{
|
|
14
|
+
source: MailSource;
|
|
15
|
+
folders: string[];
|
|
16
|
+
close?: () => Promise<void>;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
export interface MailServerRequest {
|
|
20
|
+
text: string;
|
|
21
|
+
exact?: boolean;
|
|
22
|
+
timezone?: string;
|
|
23
|
+
backend?: Backend;
|
|
24
|
+
timeMs?: number;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Asks the mailbox's server for candidates and saves the new ones, so the strict query that runs next on the store
|
|
29
|
+
* sees them: the messenger rule (cli-core docs/search/query-language.md, "Поиск на сервере мессенджера"). Each source
|
|
30
|
+
* key is `sourceKey(thread, email)`. Mail a skip or envelope rule keeps out of the store is not saved. Nothing moves the
|
|
31
|
+
* sync's saved position or coverage, and a reply after the time bound is dropped unsaved.
|
|
32
|
+
*/
|
|
33
|
+
export declare const searchMailServer: (store: MessageStore, mailbox: AccountKey, access: MailServerAccess, request: MailServerRequest) => Promise<ServerStep | undefined>;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { importEngine } from "../attachments/extract.js";
|
|
3
|
+
import { levelFor } from "../sends/permissions.js";
|
|
4
|
+
import { DEFAULT_BACKEND, SERVER_BOUNDS, SERVER_SEARCH_KEY, sourceKey, } from "../services/server-search.js";
|
|
5
|
+
import { MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
|
|
6
|
+
import { bulkBox } from "./bulk.js";
|
|
7
|
+
import { parsedEmail } from "./email-input.js";
|
|
8
|
+
import { factsOf } from "./facts.js";
|
|
9
|
+
import { mailHeadersOf } from "./headers.js";
|
|
10
|
+
import { parseMail } from "./parse.js";
|
|
11
|
+
import { storedMailRules } from "./rule-stored.js";
|
|
12
|
+
import { decide } from "./rules.js";
|
|
13
|
+
import { mailCriteriaOf } from "./server-query.js";
|
|
14
|
+
const refusal = {
|
|
15
|
+
unsupported: "this mailbox's server cannot be searched — use --backend archive",
|
|
16
|
+
offline: "--backend server needs the network — not with --offline",
|
|
17
|
+
not_allowed: "the server search is not allowed by this profile",
|
|
18
|
+
no_words: "--backend server needs words, people, a subject, a date or a state to send to the server",
|
|
19
|
+
};
|
|
20
|
+
const BOUND = Symbol("bound");
|
|
21
|
+
/** The newest first: a server's UIDs grow as mail arrives in the folder. */
|
|
22
|
+
const newest = (uids, count) => [...uids].sort((one, other) => Number(other) - Number(one)).slice(0, count);
|
|
23
|
+
/**
|
|
24
|
+
* Asks the mailbox's server for candidates and saves the new ones, so the strict query that runs next on the store
|
|
25
|
+
* sees them: the messenger rule (cli-core docs/search/query-language.md, "Поиск на сервере мессенджера"). Each source
|
|
26
|
+
* key is `sourceKey(thread, email)`. Mail a skip or envelope rule keeps out of the store is not saved. Nothing moves the
|
|
27
|
+
* sync's saved position or coverage, and a reply after the time bound is dropped unsaved.
|
|
28
|
+
*/
|
|
29
|
+
export const searchMailServer = async (store, mailbox, access, request) => {
|
|
30
|
+
const backend = request.backend ?? DEFAULT_BACKEND;
|
|
31
|
+
if (backend === "archive")
|
|
32
|
+
return undefined;
|
|
33
|
+
const quiet = request.backend === undefined;
|
|
34
|
+
const timeMs = request.timeMs ?? SERVER_BOUNDS.timeMs;
|
|
35
|
+
if (!Number.isSafeInteger(timeMs) || timeMs < 1 || timeMs > 60_000)
|
|
36
|
+
throw new CliError("validation_error", "the server time takes a whole number of milliseconds from 1 to 60000");
|
|
37
|
+
const report = { backend, skipped: null, calls: 0, returned: 0, new: 0, failed: [], complete: true };
|
|
38
|
+
const sources = new Map();
|
|
39
|
+
const skip = (reason) => {
|
|
40
|
+
if (quiet)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (backend === "server")
|
|
43
|
+
throw new CliError(reason === "not_allowed" ? "permission_error" : "validation_error", refusal[reason], {
|
|
44
|
+
reason,
|
|
45
|
+
});
|
|
46
|
+
return { report: { ...report, skipped: reason, complete: false }, sources };
|
|
47
|
+
};
|
|
48
|
+
if (access.offline)
|
|
49
|
+
return skip("offline");
|
|
50
|
+
// As tg's: `ask` and `readonly` give the archive's answer, since nobody is asked before a search.
|
|
51
|
+
if (access.readOnly || levelFor(access.permissions, SERVER_SEARCH_KEY, access.defaults).level !== "allow")
|
|
52
|
+
return skip("not_allowed");
|
|
53
|
+
const { branches, partial } = mailCriteriaOf(request.text, {
|
|
54
|
+
...(request.exact ? { exact: true } : {}),
|
|
55
|
+
...(request.timezone === undefined ? {} : { timezone: request.timezone }),
|
|
56
|
+
});
|
|
57
|
+
if (!branches.length)
|
|
58
|
+
return skip("no_words");
|
|
59
|
+
if (partial)
|
|
60
|
+
report.complete = false;
|
|
61
|
+
const pending = new Set();
|
|
62
|
+
const listed = [];
|
|
63
|
+
const validities = new Map();
|
|
64
|
+
let open = true;
|
|
65
|
+
const fail = (call, reason) => {
|
|
66
|
+
report.complete = false;
|
|
67
|
+
report.failed.push({ chat: call?.folder ?? null, reason });
|
|
68
|
+
};
|
|
69
|
+
const accountId = await store.accounts.saveAccount(mailbox, { name: mailbox.account });
|
|
70
|
+
const fresh = [];
|
|
71
|
+
let raws;
|
|
72
|
+
let opening;
|
|
73
|
+
let openFailure;
|
|
74
|
+
let unsupported = false;
|
|
75
|
+
// Opening lists the folders, a call to the server, so it runs inside the time bound too.
|
|
76
|
+
const run = async () => {
|
|
77
|
+
opening = access.open();
|
|
78
|
+
const { source, folders } = await opening.catch((error) => {
|
|
79
|
+
openFailure = error;
|
|
80
|
+
throw error;
|
|
81
|
+
});
|
|
82
|
+
const query = source.query?.bind(source);
|
|
83
|
+
if (!query) {
|
|
84
|
+
unsupported = true;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const asked = branches.flatMap((criteria) => folders.map((folder) => ({ criteria, folder })));
|
|
88
|
+
if (asked.length > SERVER_BOUNDS.maxCalls)
|
|
89
|
+
report.complete = false;
|
|
90
|
+
const calls = asked.slice(0, SERVER_BOUNDS.maxCalls);
|
|
91
|
+
for (const call of calls)
|
|
92
|
+
pending.add(call);
|
|
93
|
+
await Promise.all(calls.map(async (call) => {
|
|
94
|
+
report.calls += 1;
|
|
95
|
+
try {
|
|
96
|
+
const found = await query(call.criteria, call.folder);
|
|
97
|
+
const uids = newest(found.uids, SERVER_BOUNDS.perCall);
|
|
98
|
+
if (uids.length < found.uids.length)
|
|
99
|
+
report.complete = false;
|
|
100
|
+
const items = uids.length
|
|
101
|
+
? await source.list(new Date(0), call.folder, { headers: true, searched: { ...found, uids } })
|
|
102
|
+
: [];
|
|
103
|
+
if (!open)
|
|
104
|
+
return;
|
|
105
|
+
pending.delete(call);
|
|
106
|
+
listed.push(...items);
|
|
107
|
+
if (found.validity)
|
|
108
|
+
for (const item of items)
|
|
109
|
+
validities.set(item, found.validity);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (!open)
|
|
113
|
+
return;
|
|
114
|
+
pending.delete(call);
|
|
115
|
+
fail(call, error instanceof CliError && error.code === "rate_limited" ? "rate_limited" : "search_failed");
|
|
116
|
+
}
|
|
117
|
+
}));
|
|
118
|
+
if (!open)
|
|
119
|
+
return;
|
|
120
|
+
const unique = [...new Map(listed.map((item) => [item.messageId, item])).values()];
|
|
121
|
+
const rules = await storedMailRules(store, { address: mailbox.account });
|
|
122
|
+
const facts = factsOf(unique, rules);
|
|
123
|
+
for (const item of unique) {
|
|
124
|
+
const held = (await store.mail.email(accountId, item.messageId)) !== null;
|
|
125
|
+
const rule = decide(rules, facts(item));
|
|
126
|
+
if (!held && (rule?.action === "skip" || rule?.action === "envelope"))
|
|
127
|
+
continue;
|
|
128
|
+
report.returned += 1;
|
|
129
|
+
sources.set(sourceKey(item.threadId, item.messageId), held ? "both" : "server");
|
|
130
|
+
if (!held)
|
|
131
|
+
fresh.push(item);
|
|
132
|
+
}
|
|
133
|
+
if (fresh.length)
|
|
134
|
+
raws = await source.fetchRaw(fresh);
|
|
135
|
+
};
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const timer = setTimeout(() => controller.abort(), timeMs);
|
|
138
|
+
const stop = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
|
|
139
|
+
const bound = new Promise((resolve) => {
|
|
140
|
+
if (stop.aborted)
|
|
141
|
+
resolve(BOUND);
|
|
142
|
+
stop.addEventListener("abort", () => resolve(BOUND), { once: true });
|
|
143
|
+
});
|
|
144
|
+
let outcome;
|
|
145
|
+
try {
|
|
146
|
+
outcome = await Promise.race([
|
|
147
|
+
run().then(() => "done", () => "failed"),
|
|
148
|
+
bound,
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
open = false;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
// A late open is closed too, once it lands, so no connection outlives the answer.
|
|
155
|
+
void opening?.then((one) => one.close?.(), () => undefined);
|
|
156
|
+
}
|
|
157
|
+
if (openFailure !== undefined && backend === "server")
|
|
158
|
+
throw openFailure;
|
|
159
|
+
if (unsupported)
|
|
160
|
+
return skip("unsupported");
|
|
161
|
+
for (const call of pending)
|
|
162
|
+
fail(call, outcome === "failed" ? "search_failed" : "time_or_abort_bound");
|
|
163
|
+
if (outcome !== "done" || (fresh.length > 0 && raws === undefined)) {
|
|
164
|
+
// What was found but not read in time is the archive's to answer: none of it is claimed as the server's.
|
|
165
|
+
if (pending.size === 0)
|
|
166
|
+
fail(null, outcome === "failed" ? "search_failed" : "time_or_abort_bound");
|
|
167
|
+
for (const item of fresh)
|
|
168
|
+
sources.delete(sourceKey(item.threadId, item.messageId));
|
|
169
|
+
report.returned = sources.size;
|
|
170
|
+
return { report, sources };
|
|
171
|
+
}
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const inputs = [];
|
|
174
|
+
for (const [index, item] of fresh.entries()) {
|
|
175
|
+
const raw = raws?.[index];
|
|
176
|
+
try {
|
|
177
|
+
if (raw === undefined || raw instanceof Error)
|
|
178
|
+
throw raw ?? new Error("not read");
|
|
179
|
+
const mail = await parseMail(raw);
|
|
180
|
+
const files = await readAttachments(mail, item.messageId, { loadEngine: importEngine, maxAttachmentChars: MAX_ATTACHMENT_CHARS, at: now }, []);
|
|
181
|
+
const folder = item.folder;
|
|
182
|
+
const validity = validities.get(item);
|
|
183
|
+
inputs.push({
|
|
184
|
+
accountId,
|
|
185
|
+
externalId: item.threadId,
|
|
186
|
+
now,
|
|
187
|
+
emails: [
|
|
188
|
+
{
|
|
189
|
+
...parsedEmail(mail, item, mailHeadersOf(raw), mailbox.account),
|
|
190
|
+
mailboxes: [
|
|
191
|
+
{ externalId: folder, name: folder, kind: null },
|
|
192
|
+
...(item.labels ?? []).map((name) => ({ externalId: `label:${name}`, name, kind: "label" })),
|
|
193
|
+
...(item.headers?.bulk ? [bulkBox] : []),
|
|
194
|
+
],
|
|
195
|
+
...(validity === undefined
|
|
196
|
+
? {}
|
|
197
|
+
: { locations: [{ folder, uidvalidity: validity, uid: Number(item.uid) }] }),
|
|
198
|
+
...(item.seen === undefined ? {} : { read: item.seen }),
|
|
199
|
+
...(item.flagged === undefined ? {} : { flagged: item.flagged }),
|
|
200
|
+
attachments: files,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
sources.delete(sourceKey(item.threadId, item.messageId));
|
|
207
|
+
report.complete = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (inputs.length)
|
|
211
|
+
await store.mail.saveThreads(inputs);
|
|
212
|
+
report.new = inputs.length;
|
|
213
|
+
report.returned = sources.size;
|
|
214
|
+
return { report, sources };
|
|
215
|
+
};
|
package/dist/mail/source.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MailHeaders } from "./headers.js";
|
|
2
2
|
import type { FileStamp } from "./resume.js";
|
|
3
|
+
import type { MailCriteria } from "./server-query.js";
|
|
3
4
|
export interface Listed {
|
|
4
5
|
uid: string;
|
|
5
6
|
/** Gmail's own ids: the same in every folder, and they survive a UID reset. */
|
|
@@ -62,6 +63,11 @@ export interface MailSource {
|
|
|
62
63
|
messageId: string;
|
|
63
64
|
threadId: string;
|
|
64
65
|
};
|
|
66
|
+
/**
|
|
67
|
+
* The UIDs of `folder` the server's own search finds for `criteria`, and its UIDVALIDITY; `list` given them reads
|
|
68
|
+
* them. Absent where nothing can be asked: a file. A server reads only the parts its syntax has.
|
|
69
|
+
*/
|
|
70
|
+
query?(criteria: MailCriteria, folder: string): Promise<Searched>;
|
|
65
71
|
/** A file's size and modification time, so an unchanged file read to the end is not read again. */
|
|
66
72
|
stamp?(): Promise<FileStamp>;
|
|
67
73
|
/**
|
package/dist/mail/sync.js
CHANGED
|
@@ -2,8 +2,9 @@ import { setTimeout } from "node:timers/promises";
|
|
|
2
2
|
import { CliError } from "@wirecat/cli-base";
|
|
3
3
|
import { importEngine } from "../attachments/extract.js";
|
|
4
4
|
import { isBusy } from "../store/sqlite/transaction.js";
|
|
5
|
-
import {
|
|
5
|
+
import { FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
|
|
6
6
|
import { BULK, bulkBox } from "./bulk.js";
|
|
7
|
+
import { parsedEmail } from "./email-input.js";
|
|
7
8
|
import { factsOf } from "./facts.js";
|
|
8
9
|
import { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
|
|
9
10
|
import { dropMissing, tooManyGone } from "./gone.js";
|
|
@@ -717,18 +718,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
717
718
|
now: startedAt,
|
|
718
719
|
emails: [
|
|
719
720
|
{
|
|
720
|
-
|
|
721
|
-
subject: mail.subject,
|
|
722
|
-
from: mail.from === null ? null : addressOf(mail.from),
|
|
723
|
-
to: mail.to.map(addressOf),
|
|
724
|
-
cc: mail.cc.map(addressOf),
|
|
725
|
-
bcc: mail.bcc.map(addressOf),
|
|
726
|
-
sentAt: null,
|
|
727
|
-
receivedAt: Date.parse(item.receivedAt),
|
|
728
|
-
bodyText: mail.text || null,
|
|
729
|
-
size: item.size ?? null,
|
|
730
|
-
...(Object.keys(header).length ? { headers: header } : {}),
|
|
731
|
-
outgoing: mail.from === null ? null : mail.from.email === key.account,
|
|
721
|
+
...parsedEmail(mail, item, header, key.account),
|
|
732
722
|
mailboxes: inMailboxes(item),
|
|
733
723
|
...locationOf(item),
|
|
734
724
|
...flagsOf(item),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FileStamp } from "../resume.js";
|
|
2
|
+
import type { MailCriteria } from "../server-query.js";
|
|
2
3
|
import type { MailSource } from "../source.js";
|
|
3
4
|
export type FakePerson = [name: string | null, address: string];
|
|
4
5
|
/** One message of a fake Gmail-like mailbox: its ids, envelope and the parts `rawOf` writes as RFC 5322. */
|
|
@@ -61,6 +62,11 @@ export interface FakeMailbox {
|
|
|
61
62
|
fetchBatch?: number;
|
|
62
63
|
/** What the source was asked: the UIDs of each listing, and `msgid:<id>` for each `find`. */
|
|
63
64
|
asked?: string[];
|
|
65
|
+
/** Each server search `query` was given, with its folder, so a test sees what was sent. */
|
|
66
|
+
queried?: {
|
|
67
|
+
criteria: MailCriteria;
|
|
68
|
+
folder: string;
|
|
69
|
+
}[];
|
|
64
70
|
}
|
|
65
71
|
/** The RFC 5322 bytes of a fake message, CRLF line ends, the body and attachment in base64. */
|
|
66
72
|
export declare const rawOf: (m: FakeMail) => Uint8Array;
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { GMAIL_ALL_MAIL } from "../folders.js";
|
|
3
3
|
import { headersOf } from "../headers.js";
|
|
4
|
+
/** As IMAP SEARCH matches: a case-blind substring of the field, every part required. */
|
|
5
|
+
const matches = (m, criteria) => {
|
|
6
|
+
const has = (field, value) => field.toLowerCase().includes(value.toLowerCase());
|
|
7
|
+
const people = (list) => list.map(person).join(", ");
|
|
8
|
+
const text = `${m.subject}\n${m.text ?? m.html ?? ""}`;
|
|
9
|
+
const at = Date.parse(m.receivedAt);
|
|
10
|
+
return (criteria.words.every((word) => has(`${people([m.from])}\n${people(m.to)}\n${text}`, word)) &&
|
|
11
|
+
criteria.from.every((value) => has(person(m.from), value)) &&
|
|
12
|
+
criteria.to.every((value) => has(people(m.to), value)) &&
|
|
13
|
+
criteria.cc.every((value) => has(people(m.cc ?? []), value)) &&
|
|
14
|
+
criteria.subject.every((value) => has(m.subject, value)) &&
|
|
15
|
+
criteria.body.every((value) => has(m.text ?? m.html ?? "", value)) &&
|
|
16
|
+
(criteria.since === undefined || at >= criteria.since) &&
|
|
17
|
+
(criteria.before === undefined || at < criteria.before) &&
|
|
18
|
+
(criteria.seen === undefined || (m.seen ?? false) === criteria.seen) &&
|
|
19
|
+
(criteria.flagged === undefined || (m.flagged ?? false) === criteria.flagged));
|
|
20
|
+
};
|
|
4
21
|
const TRASH = "[Gmail]/Bin";
|
|
5
22
|
const person = ([name, address]) => (name ? `"${name}" <${address}>` : address);
|
|
6
23
|
/** The RFC 5322 bytes of a fake message, CRLF line ends, the body and attachment in base64. */
|
|
@@ -116,6 +133,18 @@ export const fakeMailSource = (mailbox) => {
|
|
|
116
133
|
},
|
|
117
134
|
}
|
|
118
135
|
: {}),
|
|
136
|
+
query: async (criteria, folder) => {
|
|
137
|
+
count("search");
|
|
138
|
+
if (mailbox.searchFails)
|
|
139
|
+
throw failure(mailbox.searchFails);
|
|
140
|
+
mailbox.queried?.push({ criteria, folder });
|
|
141
|
+
return {
|
|
142
|
+
validity: String(mailbox.validity ?? 12),
|
|
143
|
+
uids: inFolder(folder)
|
|
144
|
+
.filter((m) => matches(m, criteria))
|
|
145
|
+
.map((m) => m.uid),
|
|
146
|
+
};
|
|
147
|
+
},
|
|
119
148
|
find: async (messageIds, folder, options) => {
|
|
120
149
|
for (const id of messageIds)
|
|
121
150
|
mailbox.asked?.push(`msgid:${id}`);
|
package/dist/ports/app.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface AppIdentity {
|
|
|
25
25
|
* tg, max and wa print what they always did.
|
|
26
26
|
*/
|
|
27
27
|
messenger?: boolean;
|
|
28
|
+
/** A CLI that is not a messenger yet sends to people, as mail does: it keeps the hourly limit, `sends_per_hour`. */
|
|
29
|
+
sends?: true;
|
|
28
30
|
/**
|
|
29
31
|
* How long one request to the provider usually takes, in ms: a sync's budget is the deadline, less its margin,
|
|
30
32
|
* divided by it (`requestsFor`). 2 s when unset.
|
|
@@ -71,8 +71,8 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
71
71
|
const { database } = context;
|
|
72
72
|
const corpus = execution.corpus === "mail" ? MAIL : MESSAGES;
|
|
73
73
|
const mail = corpus === MAIL;
|
|
74
|
-
if (mail && (execution.
|
|
75
|
-
throw new CliError("validation_error", "mail has no
|
|
74
|
+
if (mail && (execution.conversationIds || execution.conversationSince !== undefined))
|
|
75
|
+
throw new CliError("validation_error", "mail has no conversations to search within");
|
|
76
76
|
const started = context.now();
|
|
77
77
|
const budget = { work: 0 };
|
|
78
78
|
let states = 0, expansions = 0;
|
|
@@ -216,11 +216,11 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
|
|
|
216
216
|
where = combine([
|
|
217
217
|
where,
|
|
218
218
|
{
|
|
219
|
-
sql: `m.id IN (SELECT picked.id FROM json_each(?) j CROSS JOIN accounts allowed CROSS JOIN chats scoped CROSS JOIN messages picked
|
|
219
|
+
sql: `m.id IN (SELECT picked.id FROM json_each(?) j CROSS JOIN accounts allowed CROSS JOIN ${mail ? "email_threads" : "chats"} scoped CROSS JOIN ${mail ? "emails" : "messages"} picked
|
|
220
220
|
WHERE (${execution.accounts.map(() => "(allowed.provider=? AND allowed.external_id=?)").join(" OR ") || "0"})
|
|
221
221
|
AND allowed.provider=json_extract(j.value,'$.provider') AND allowed.external_id=json_extract(j.value,'$.account')
|
|
222
222
|
AND scoped.account_id=allowed.id AND scoped.external_id=json_extract(j.value,'$.chatId')
|
|
223
|
-
AND picked
|
|
223
|
+
AND picked.${mail ? "email_thread_id" : "chat_id"}=scoped.id AND picked.external_id=json_extract(j.value,'$.id'))`,
|
|
224
224
|
params: [JSON.stringify(execution.only), ...execution.accounts.flatMap((key) => [key.provider, key.account])],
|
|
225
225
|
exact: true,
|
|
226
226
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.525.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",
|