@wirecat/cli-core 0.575.0 → 0.577.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 +37 -0
- package/dist/cli/findings-input.js +2 -0
- package/dist/cli/messenger/tasks-command.js +2 -0
- package/dist/domain/external-links.d.ts +13 -0
- package/dist/domain/external-links.js +30 -0
- package/dist/entities/index.d.ts +15 -0
- package/dist/entities/mail-findings.d.ts +6 -0
- package/dist/entities/mail-findings.js +6 -0
- package/dist/entities/task-results.d.ts +20 -0
- package/dist/entities/task-results.js +4 -0
- package/dist/entities/task.d.ts +4 -0
- package/dist/entities/task.js +4 -0
- package/dist/errors/reasons.js +5 -0
- package/dist/mail/cli/bulk-settings.d.ts +6 -0
- package/dist/mail/cli/bulk-settings.js +65 -0
- package/dist/mail/cli/index.d.ts +4 -0
- package/dist/mail/cli/index.js +3 -1
- package/dist/mail/cli/marks.d.ts +6 -0
- package/dist/mail/cli/marks.js +69 -1
- package/dist/mail/cli/review.js +34 -13
- package/dist/mail/findings.d.ts +2 -0
- package/dist/mail/findings.js +15 -1
- package/dist/mail/index.d.ts +2 -1
- package/dist/mail/index.js +2 -1
- package/dist/mail/marks.d.ts +48 -7
- package/dist/mail/marks.js +158 -16
- package/dist/mail/newsletter.d.ts +52 -0
- package/dist/mail/newsletter.js +189 -0
- package/dist/mail/review.d.ts +2 -0
- package/dist/meetings/findings-rules.d.ts +4 -0
- package/dist/store/sqlite/email-activity.d.ts +2 -0
- package/dist/store/sqlite/email-activity.js +4 -0
- package/dist/store/sqlite/findings-core.js +2 -0
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +10 -0
- package/dist/store/sqlite/orders.d.ts +4 -0
- package/dist/store/sqlite/orders.js +6 -3
- package/dist/store/sqlite/schema.d.ts +64 -0
- package/dist/store/sqlite/schema.js +5 -0
- package/dist/store/sqlite/work-tasks.d.ts +7 -0
- package/dist/store/sqlite/work-tasks.js +10 -0
- package/package.json +1 -1
package/dist/mail/cli/review.js
CHANGED
|
@@ -19,6 +19,7 @@ import { formatReference } from "../../domain/references.js";
|
|
|
19
19
|
import { AGENDA_DEFAULTS } from "../../services/agenda.js";
|
|
20
20
|
import { REVIEW_DAYS, reviewStart } from "../../services/inbox.js";
|
|
21
21
|
import { readPastMark } from "../marks.js";
|
|
22
|
+
import { BULK_TRIAGE_DEFAULTS } from "../newsletter.js";
|
|
22
23
|
import { hitText } from "../read.js";
|
|
23
24
|
import { OWED_DAYS, REVIEW_THREADS, reviewMail } from "../review.js";
|
|
24
25
|
const DAY = 86_400_000;
|
|
@@ -74,9 +75,10 @@ const waitingHours = (value) => value === true ? AGENDA_DEFAULTS.waiting / 3_600
|
|
|
74
75
|
const quoted = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
75
76
|
/** A file name for the mailbox's points: they describe the mailbox, whichever profile reads it. */
|
|
76
77
|
const pointsName = (mailbox) => encodeURIComponent(mailbox.account);
|
|
77
|
-
const markText = (marks) => {
|
|
78
|
+
const markText = (marks, excerptOnly = false) => {
|
|
78
79
|
const said = [
|
|
79
|
-
marks?.bulk ? "bulk" : "",
|
|
80
|
+
marks?.bulk || excerptOnly ? "bulk" : "",
|
|
81
|
+
excerptOnly ? "excerpt only" : "",
|
|
80
82
|
marks?.listId ? `list ${marks.listId}` : "",
|
|
81
83
|
marks?.ruleId ? `rule ${marks.ruleId}` : "",
|
|
82
84
|
];
|
|
@@ -84,9 +86,11 @@ const markText = (marks) => {
|
|
|
84
86
|
return shown ? `\n [${shown}]` : "";
|
|
85
87
|
};
|
|
86
88
|
const reviewAnswer = (found, mailbox, fresh, step) => {
|
|
89
|
+
// Excerpts come after the mail written to the owner, so an agent with a budget reads that first.
|
|
87
90
|
const read = found.chats
|
|
88
91
|
.flatMap((chat) => chat.messages.map((message) => ({ ...message, chatTitle: chat.title })))
|
|
89
|
-
.sort((a, b) =>
|
|
92
|
+
.sort((a, b) => Number(a.excerptOnly ?? false) - Number(b.excerptOnly ?? false) ||
|
|
93
|
+
Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
90
94
|
const cut = found.chats.filter((chat) => chat.more);
|
|
91
95
|
const ref = (thread) => formatReference({ type: "chat", ...mailbox, chat: thread });
|
|
92
96
|
const turns = found.unanswered !== undefined || found.waiting !== undefined;
|
|
@@ -126,7 +130,8 @@ const reviewAnswer = (found, mailbox, fresh, step) => {
|
|
|
126
130
|
...summaryOf(message, mailbox),
|
|
127
131
|
email: {
|
|
128
132
|
...messageItem(message, mailbox).email,
|
|
129
|
-
bulk: message.marks?.bulk ?? false,
|
|
133
|
+
bulk: (message.marks?.bulk ?? false) || message.excerptOnly === true,
|
|
134
|
+
...(message.excerptOnly ? { excerpt_only: true } : {}),
|
|
130
135
|
list_id: message.marks?.listId ?? null,
|
|
131
136
|
rule_id: message.marks?.ruleId ?? null,
|
|
132
137
|
...(message.marks
|
|
@@ -150,16 +155,22 @@ const holderReview = async (command, mail, holder, query) => {
|
|
|
150
155
|
const { unanswered, waiting } = command.opts();
|
|
151
156
|
if (typedQuery(query).trim() || unanswered !== undefined || waiting !== undefined)
|
|
152
157
|
throw new CliError("validation_error", "--holder reads past its own mark — not with a query, --unanswered or --waiting");
|
|
158
|
+
const triage = mail.bulkTriage?.(command) ?? BULK_TRIAGE_DEFAULTS;
|
|
153
159
|
const { read, mailbox, marks } = await mail.read(command, async (store, mailbox) => {
|
|
154
|
-
const read = await readPastMark(store, mailbox, holder);
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
: new Map();
|
|
160
|
+
const read = await readPastMark(store, mailbox, holder, { triage });
|
|
161
|
+
const ids = [...read.emails, ...read.excerpts.map(({ email }) => email)].map((one) => one.id);
|
|
162
|
+
const account = ids.length ? await store.accounts.storedAccount(mailbox) : undefined;
|
|
163
|
+
const marks = account ? await store.mail.emailMarks(account.id, ids) : new Map();
|
|
159
164
|
return { read, mailbox, marks };
|
|
160
165
|
});
|
|
161
166
|
// An agent reads this: earlier mail and signatures stay out of its text; the fingerprint is over the stored body.
|
|
162
|
-
const emails =
|
|
167
|
+
const emails = [
|
|
168
|
+
...read.emails.map((email) => ({ ...email, text: mailOwnText(email.text) })),
|
|
169
|
+
...read.excerpts.map(({ email, excerpt }) => {
|
|
170
|
+
const subject = email.email?.subject;
|
|
171
|
+
return { ...email, text: subject ? `${subject}\n\n${excerpt}` : excerpt, excerptOnly: true };
|
|
172
|
+
}),
|
|
173
|
+
];
|
|
163
174
|
const chats = new Map();
|
|
164
175
|
for (const email of emails) {
|
|
165
176
|
const chat = chats.get(email.chatId) ?? {
|
|
@@ -193,13 +204,22 @@ const holderReview = async (command, mail, holder, query) => {
|
|
|
193
204
|
...(skipped.length
|
|
194
205
|
? [{ field: "items", why: `left out: ${skipped.map(([reason, count]) => `${count} ${reason}`).join(", ")}` }]
|
|
195
206
|
: []),
|
|
207
|
+
...(read.excerpts.length
|
|
208
|
+
? [
|
|
209
|
+
{
|
|
210
|
+
field: "items",
|
|
211
|
+
why: `${read.excerpts.length} bulk emails (excerpt_only) as their subject and first lines: ` +
|
|
212
|
+
"read one whole with `messages show <ref>` when it is an order, an invoice or a request",
|
|
213
|
+
},
|
|
214
|
+
]
|
|
215
|
+
: []),
|
|
196
216
|
];
|
|
197
217
|
const output = outputFor(command);
|
|
198
218
|
renderAnswer(command, output, answer, () => {
|
|
199
219
|
output.renderer.note(read.mark
|
|
200
220
|
? `${holder}: past ${read.mark.throughId}; acknowledge with --cursor ${read.cursor}${read.more ? "; more after these" : ""}`
|
|
201
221
|
: `${holder} has no mark yet: it starts from now — acknowledge with --cursor ${read.cursor}`);
|
|
202
|
-
return emails.map((hit) => `${hitText(hit)}${markText(marks.get(hit.id))}\n`).join("");
|
|
222
|
+
return emails.map((hit) => `${hitText(hit)}${markText(marks.get(hit.id), hit.excerptOnly)}\n`).join("");
|
|
203
223
|
});
|
|
204
224
|
};
|
|
205
225
|
/**
|
|
@@ -225,8 +245,9 @@ export const mailReviewCommand = (mail) => annotate(new Command("review"), {
|
|
|
225
245
|
.option("--unanswered [duration]", "only threads whose newest email came to you, in To or Cc, with no answer for this long — 4h, 1d; 24h if not given")
|
|
226
246
|
.option("--waiting [duration]", "only threads where you wrote last and nobody answered for this long — 3d; 7d if not given")
|
|
227
247
|
.option("--holder <name>", "read for this holder — an agent or a pipeline — what is stored past its processing mark, by store order; " +
|
|
228
|
-
"
|
|
229
|
-
"
|
|
248
|
+
"newsletters, envelopes, drafts, suspicious senders and partial mail left out; other bulk mail after the rest, " +
|
|
249
|
+
"as its subject and first lines (excerpt_only); each text without quoted earlier mail or the signature; " +
|
|
250
|
+
"`messages acknowledge` moves the mark")
|
|
230
251
|
.option("--new", "what changed since the last `review --new` of this mailbox, a point per thread — for scheduled runs")
|
|
231
252
|
.addHelpText("after", `
|
|
232
253
|
Without a date, --unanswered and --waiting look at threads active in the last ${OWED_DAYS} days.
|
package/dist/mail/findings.d.ts
CHANGED
|
@@ -99,6 +99,8 @@ export interface MailOrderFinding extends Cited {
|
|
|
99
99
|
invoice?: string;
|
|
100
100
|
payment?: string;
|
|
101
101
|
project?: string;
|
|
102
|
+
/** The order's page at the shop, http or https. */
|
|
103
|
+
sourceUrl?: string;
|
|
102
104
|
reason: string;
|
|
103
105
|
}
|
|
104
106
|
/** A later mail about a stored order, found by its shop and number: shipped, delivered, cancelled, returned. */
|
package/dist/mail/findings.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { EXTERNAL_ID_CHARS, externalIdOf, SOURCE_URL_CHARS, sourceUrlOf } from "../domain/external-links.js";
|
|
2
3
|
import { INVOICE_DIRECTIONS, MONEY_DIRECTIONS } from "../domain/finance.js";
|
|
3
4
|
import { MEMORY_KINDS, MEMORY_SCOPES } from "../domain/knowledge.js";
|
|
4
5
|
import { formatLocator, parseLocator } from "../domain/locator.js";
|
|
@@ -59,8 +60,19 @@ const optional = (key, value, read) => (value === undefined ? {} : { [key]: read
|
|
|
59
60
|
const one = (raw, ids) => {
|
|
60
61
|
if (!raw || typeof raw !== "object")
|
|
61
62
|
return invalid("a finding is an object");
|
|
62
|
-
if (raw.kind === "
|
|
63
|
+
if (raw.kind === "fact")
|
|
63
64
|
return validateFinding(raw, RULES, ids);
|
|
65
|
+
if (raw.kind === "task") {
|
|
66
|
+
const { externalId, sourceUrl, ...task } = raw;
|
|
67
|
+
return {
|
|
68
|
+
...validateFinding(task, RULES, ids),
|
|
69
|
+
...optional("externalId", externalId, (value) => {
|
|
70
|
+
const { provider, id } = externalIdOf(findingText(value, EXTERNAL_ID_CHARS + 40));
|
|
71
|
+
return `${provider}:${id}`;
|
|
72
|
+
}),
|
|
73
|
+
...optional("sourceUrl", sourceUrl, (value) => sourceUrlOf(findingText(value, SOURCE_URL_CHARS))),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
64
76
|
const id = findingId(raw.id, ids);
|
|
65
77
|
const evidence = validEvidence(raw.evidence, RULES);
|
|
66
78
|
const reference = (value, pattern, what) => {
|
|
@@ -247,6 +259,7 @@ const one = (raw, ids) => {
|
|
|
247
259
|
"invoice",
|
|
248
260
|
"payment",
|
|
249
261
|
"project",
|
|
262
|
+
"sourceUrl",
|
|
250
263
|
"reason",
|
|
251
264
|
"evidence",
|
|
252
265
|
], RULES.singular);
|
|
@@ -266,6 +279,7 @@ const one = (raw, ids) => {
|
|
|
266
279
|
...optional("invoice", raw.invoice, (value) => reference(value, INVOICE, "an order's invoice")),
|
|
267
280
|
...optional("payment", raw.payment, (value) => reference(value, PAYMENT, "an order's payment")),
|
|
268
281
|
...optional("project", raw.project, (value) => reference(value, PROJECT, "a project")),
|
|
282
|
+
...optional("sourceUrl", raw.sourceUrl, (value) => sourceUrlOf(findingText(value, SOURCE_URL_CHARS), "order.source-url")),
|
|
269
283
|
reason: findingText(raw.reason, 2000),
|
|
270
284
|
evidence,
|
|
271
285
|
};
|
package/dist/mail/index.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export { dropMissing, type Gone, tooManyGone } from "./gone.js";
|
|
|
9
9
|
export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
|
|
10
10
|
export { type MailIdsInput, mailIds } from "./ids.js";
|
|
11
11
|
export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
|
|
12
|
-
export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MailWaiting, type MarkRead, mailWaiting, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
|
|
12
|
+
export { acknowledgeMark, advancePastSkipped, type BulkExcerpt, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MailWaiting, type MarkRead, type MarkRewind, mailWaiting, markOf, marksOf, type RewoundMark, readPastMark, rewindMark, type SkipReason, } from "./marks.js";
|
|
13
|
+
export { BULK_TRIAGE_DEFAULTS, type BulkSorted, type BulkTriage, excerptOf, NEWSLETTER_DEFAULTS, NEWSLETTER_SENDERS, NEWSLETTER_WORDS, type NewsletterCues, newsletterSorter, ORDER_WORDS, } from "./newsletter.js";
|
|
13
14
|
export { type MailTurn, mailTurn } from "./owed.js";
|
|
14
15
|
export { type Address, htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
|
|
15
16
|
export { PARTIAL, type PartialOutcome } from "./partial.js";
|
package/dist/mail/index.js
CHANGED
|
@@ -9,7 +9,8 @@ export { dropMissing, tooManyGone } from "./gone.js";
|
|
|
9
9
|
export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
|
|
10
10
|
export { mailIds } from "./ids.js";
|
|
11
11
|
export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
|
|
12
|
-
export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, mailWaiting, markOf, marksOf, readPastMark, } from "./marks.js";
|
|
12
|
+
export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, mailWaiting, markOf, marksOf, readPastMark, rewindMark, } from "./marks.js";
|
|
13
|
+
export { BULK_TRIAGE_DEFAULTS, excerptOf, NEWSLETTER_DEFAULTS, NEWSLETTER_SENDERS, NEWSLETTER_WORDS, newsletterSorter, ORDER_WORDS, } from "./newsletter.js";
|
|
13
14
|
export { mailTurn } from "./owed.js";
|
|
14
15
|
export { htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, parseMail, } from "./parse.js";
|
|
15
16
|
export { PARTIAL } from "./partial.js";
|
package/dist/mail/marks.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { AccountKey, MessageStore, StoredHit } from "../store/store.js";
|
|
2
|
+
import { type BulkTriage } from "./newsletter.js";
|
|
2
3
|
/** A holder's name: what `--holder` takes and the mark's key ends with. */
|
|
3
4
|
export declare const HOLDER: RegExp;
|
|
4
5
|
/** At most this many emails are read past the mark in one go; the rest come with the next read. */
|
|
5
6
|
export declare const MARK_BATCH = 200;
|
|
6
7
|
/** A partial email waits this long to be stored whole, then is skipped. */
|
|
7
8
|
export declare const HOLD_PARTIAL_MS: number;
|
|
8
|
-
export type SkipReason = "bulk" | "envelope" | "partial" | "draft" | "suspicious";
|
|
9
|
+
export type SkipReason = "bulk" | "newsletter" | "envelope" | "partial" | "draft" | "suspicious";
|
|
9
10
|
/**
|
|
10
11
|
* How far one holder — an agent, a pipeline — has processed an account's mail, in the shared store (`sync_cursors`,
|
|
11
12
|
* key `consumer:<holder>`). It counts by the store's insert order (`emails.id`), so mail stored late, older than what
|
|
@@ -29,9 +30,19 @@ export declare const markOf: (store: MessageStore, mailbox: AccountKey, holder:
|
|
|
29
30
|
export declare const marksOf: (store: MessageStore, mailbox: AccountKey) => Promise<MailMark[]>;
|
|
30
31
|
/** The mark's state, as a cursor `acknowledge` takes back: the last id read, and when it was read. */
|
|
31
32
|
export declare const cursorOf: (throughId: number, readAt: number) => string;
|
|
33
|
+
/** A bulk email that is not surely a newsletter: the holder gets its subject and first lines only. */
|
|
34
|
+
export interface BulkExcerpt {
|
|
35
|
+
email: StoredHit;
|
|
36
|
+
/** The first lines of its own text, without the subject. */
|
|
37
|
+
excerpt: string;
|
|
38
|
+
/** What kept it, or the newsletter signs too few to leave it out. */
|
|
39
|
+
cues: string[];
|
|
40
|
+
}
|
|
32
41
|
export interface MarkRead {
|
|
33
42
|
/** The emails for the holder, oldest first; bulk, envelopes, drafts, suspicious senders and partial mail left out. */
|
|
34
43
|
emails: StoredHit[];
|
|
44
|
+
/** Bulk mail past the mark that is not surely a newsletter, oldest first; none when the triage gives no excerpts. */
|
|
45
|
+
excerpts: BulkExcerpt[];
|
|
35
46
|
/** What `acknowledge --cursor` takes once the holder is done with these. */
|
|
36
47
|
cursor: string;
|
|
37
48
|
/** More is stored after this batch: read again after acknowledging. */
|
|
@@ -49,6 +60,7 @@ export interface MarkRead {
|
|
|
49
60
|
export declare const readPastMark: (store: MessageStore, mailbox: AccountKey, holder: string, options?: {
|
|
50
61
|
now?: number;
|
|
51
62
|
limit?: number;
|
|
63
|
+
triage?: BulkTriage;
|
|
52
64
|
}) => Promise<MarkRead>;
|
|
53
65
|
/** Mail past a holder's marks that would reach it: how many emails, and when the oldest of them was stored. */
|
|
54
66
|
export interface MailWaiting {
|
|
@@ -60,20 +72,49 @@ export interface MailWaiting {
|
|
|
60
72
|
}
|
|
61
73
|
/**
|
|
62
74
|
* What waits for the holder in every stored mailbox, read only: what a schedule asks before it starts the holder's
|
|
63
|
-
* agent, so a run with nothing new costs no model call.
|
|
64
|
-
* do not count.
|
|
75
|
+
* agent, so a run with nothing new costs no model call. Newsletters, envelopes, drafts, suspicious senders and partial
|
|
76
|
+
* mail do not count; other bulk mail counts as the excerpt the holder gets, unless the triage gives none.
|
|
65
77
|
*/
|
|
66
|
-
export declare const mailWaiting: (store: MessageStore, holder: string, now?: number) => Promise<MailWaiting>;
|
|
78
|
+
export declare const mailWaiting: (store: MessageStore, holder: string, now?: number, triage?: BulkTriage) => Promise<MailWaiting>;
|
|
67
79
|
/**
|
|
68
|
-
* Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip —
|
|
69
|
-
* envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
|
|
80
|
+
* Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — newsletters,
|
|
81
|
+
* bulk mail the triage gives no excerpt of, envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
|
|
70
82
|
* does not read it all again. Suspicious senders' emails stay on the mark to be offered again. It stops at the first
|
|
71
83
|
* email the holder reads. A store busy with a sync is left alone: the next check moves it.
|
|
72
84
|
*/
|
|
73
|
-
export declare const advancePastSkipped: (store: MessageStore, holder: string, now?: number) => Promise<void>;
|
|
85
|
+
export declare const advancePastSkipped: (store: MessageStore, holder: string, now?: number, triage?: BulkTriage) => Promise<void>;
|
|
74
86
|
/**
|
|
75
87
|
* Moves the holder's mark to the cursor its read gave, never back. An email whole when it was read counts read; one
|
|
76
88
|
* still partial, or changed after the read (it may have become whole since), is held and comes with the next read.
|
|
77
89
|
* The same cursor twice changes nothing more.
|
|
78
90
|
*/
|
|
79
91
|
export declare const acknowledgeMark: (store: MessageStore, mailbox: AccountKey, holder: string, cursor: string, now?: number) => Promise<MailMark>;
|
|
92
|
+
/** Where the owner moves a holder's mark back to: the emails received since a time, or the last n it was given. */
|
|
93
|
+
export type MarkRewind = {
|
|
94
|
+
since: number;
|
|
95
|
+
} | {
|
|
96
|
+
last: number;
|
|
97
|
+
};
|
|
98
|
+
export interface RewoundMark {
|
|
99
|
+
mark: MailMark;
|
|
100
|
+
/** Where the mark was before. */
|
|
101
|
+
fromId: number;
|
|
102
|
+
/** The emails between the new mark and the old one the holder reads whole again. */
|
|
103
|
+
again: number;
|
|
104
|
+
/** Bulk emails between them: excerpts again, unless they are surely newsletters. */
|
|
105
|
+
bulk: number;
|
|
106
|
+
/** Emails between them the holder skips again: drafts, envelopes, suspicious senders, partial mail. */
|
|
107
|
+
skipped: number;
|
|
108
|
+
/** The mark is moved only when not dry. */
|
|
109
|
+
dry: boolean;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Moves the holder's mark back, so its next reviews give it that mail again: past every email received since
|
|
113
|
+
* `since`, or past the last `last` emails it was given. The owner's alone: a holder only ever moves its own mark
|
|
114
|
+
* forward. The id is the store's insert order, so an older email stored after the earliest one picked is read again too;
|
|
115
|
+
* the answer counts them all.
|
|
116
|
+
*/
|
|
117
|
+
export declare const rewindMark: (store: MessageStore, mailbox: AccountKey, holder: string, to: MarkRewind, { now, dry }?: {
|
|
118
|
+
now?: number;
|
|
119
|
+
dry?: boolean;
|
|
120
|
+
}) => Promise<RewoundMark>;
|
package/dist/mail/marks.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { mailOwnText } from "../conversations/mail-text.js";
|
|
2
3
|
import { isBusy } from "../store/sqlite/transaction.js";
|
|
4
|
+
import { BULK_TRIAGE_DEFAULTS, newsletterSorter } from "./newsletter.js";
|
|
3
5
|
/** A holder's name: what `--holder` takes and the mark's key ends with. */
|
|
4
6
|
export const HOLDER = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
5
7
|
/** At most this many emails are read past the mark in one go; the rest come with the next read. */
|
|
@@ -11,7 +13,14 @@ const MOST_HELD = 500;
|
|
|
11
13
|
/** A cursor read at most this far in the future is a clock apart, not a forgery. */
|
|
12
14
|
const CLOCK_SLACK_MS = 60_000;
|
|
13
15
|
const PREFIX = "consumer:";
|
|
14
|
-
const noneSkipped = () => ({
|
|
16
|
+
const noneSkipped = () => ({
|
|
17
|
+
bulk: 0,
|
|
18
|
+
newsletter: 0,
|
|
19
|
+
envelope: 0,
|
|
20
|
+
partial: 0,
|
|
21
|
+
draft: 0,
|
|
22
|
+
suspicious: 0,
|
|
23
|
+
});
|
|
15
24
|
const keyOf = (holder) => {
|
|
16
25
|
if (!HOLDER.test(holder))
|
|
17
26
|
throw new CliError("validation_error", `a holder is lower-case letters, digits and dashes, not "${holder}"`);
|
|
@@ -83,14 +92,54 @@ const parseCursor = (cursor) => {
|
|
|
83
92
|
* empty and gives the cursor of the newest stored email.
|
|
84
93
|
*/
|
|
85
94
|
export const readPastMark = async (store, mailbox, holder, options = {}) => {
|
|
86
|
-
const { wanted, oldestStoredAt: _, ...read } = await examine(store, mailbox, holder, options);
|
|
87
|
-
|
|
95
|
+
const { wanted, sorted, oldestStoredAt: _, ...read } = await examine(store, mailbox, holder, options);
|
|
96
|
+
const excerpts = wanted.flatMap((id) => {
|
|
97
|
+
const one = sorted.get(id);
|
|
98
|
+
return one ? [{ email: one.email, excerpt: one.excerpt, cues: one.cues }] : [];
|
|
99
|
+
});
|
|
100
|
+
const emails = await store.mail.emailHits(wanted.filter((id) => !sorted.has(id)));
|
|
101
|
+
return { emails, excerpts, ...read };
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* The batch's bulk emails a holder might read, sorted by `newsletterSorter`, and where the batch ends so it holds at
|
|
105
|
+
* most `perRead` excerpts. Drafts, envelopes, suspicious senders and partial mail keep their own verdicts.
|
|
106
|
+
*/
|
|
107
|
+
const sortBulk = async (store, batch, triage) => {
|
|
108
|
+
const sorted = new Map();
|
|
109
|
+
if (!triage.excerpts)
|
|
110
|
+
return { sorted, end: batch.length };
|
|
111
|
+
const sort = newsletterSorter(triage.cues);
|
|
112
|
+
let excerpts = 0;
|
|
113
|
+
for (const [index, marked] of batch.entries()) {
|
|
114
|
+
if (!marked.bulk || marked.draft || marked.envelope || marked.demoted || marked.partial)
|
|
115
|
+
continue;
|
|
116
|
+
const [email] = await store.mail.emailHits([marked.id]);
|
|
117
|
+
if (!email)
|
|
118
|
+
continue;
|
|
119
|
+
const subject = email.email?.subject ?? null;
|
|
120
|
+
const body = subject && email.text.startsWith(`${subject}\n\n`) ? email.text.slice(subject.length + 2) : email.text;
|
|
121
|
+
const one = sort({ subject, text: mailOwnText(body), from: email.email?.from?.address ?? null });
|
|
122
|
+
if (one.verdict === "excerpt" && excerpts === triage.perRead)
|
|
123
|
+
return { sorted, end: index };
|
|
124
|
+
if (one.verdict === "excerpt")
|
|
125
|
+
excerpts += 1;
|
|
126
|
+
sorted.set(marked.id, { ...one, email });
|
|
127
|
+
}
|
|
128
|
+
return { sorted, end: batch.length };
|
|
88
129
|
};
|
|
89
|
-
/** `readPastMark`
|
|
90
|
-
const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH, after }) => {
|
|
130
|
+
/** `readPastMark` before it reads the emails: the store ids it would read by their verdicts, and the bulk sorted. */
|
|
131
|
+
const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH, after, triage = BULK_TRIAGE_DEFAULTS, }) => {
|
|
91
132
|
const saved = await markOf(store, mailbox, holder);
|
|
92
133
|
const account = await accountOf(store, mailbox);
|
|
93
|
-
const empty = {
|
|
134
|
+
const empty = {
|
|
135
|
+
wanted: [],
|
|
136
|
+
sorted: new Map(),
|
|
137
|
+
oldestStoredAt: null,
|
|
138
|
+
more: false,
|
|
139
|
+
held: 0,
|
|
140
|
+
skipped: noneSkipped(),
|
|
141
|
+
mark: saved,
|
|
142
|
+
};
|
|
94
143
|
if (!account)
|
|
95
144
|
return { ...empty, cursor: cursorOf(0, now) };
|
|
96
145
|
if (!saved)
|
|
@@ -103,7 +152,8 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
|
|
|
103
152
|
const newer = found.filter((email) => email.id > mark.throughId);
|
|
104
153
|
const unsettled = newer.findIndex((email) => email.storedAt > settledAt);
|
|
105
154
|
const fresh = unsettled === -1 ? newer : newer.slice(0, unsettled);
|
|
106
|
-
const
|
|
155
|
+
const { sorted, end } = await sortBulk(store, fresh.slice(0, limit), triage);
|
|
156
|
+
const batch = fresh.slice(0, end);
|
|
107
157
|
const examined = [...found.filter((email) => email.id <= mark.throughId), ...batch];
|
|
108
158
|
const skipped = noneSkipped();
|
|
109
159
|
const wanted = [];
|
|
@@ -111,7 +161,12 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
|
|
|
111
161
|
let held = 0;
|
|
112
162
|
for (const email of examined) {
|
|
113
163
|
const said = verdict(email, now);
|
|
114
|
-
|
|
164
|
+
const bulk = sorted.get(email.id);
|
|
165
|
+
if (bulk?.verdict === "newsletter") {
|
|
166
|
+
if (email.id > mark.throughId)
|
|
167
|
+
skipped.newsletter += 1;
|
|
168
|
+
}
|
|
169
|
+
else if (said === "read" || bulk) {
|
|
115
170
|
wanted.push(email.id);
|
|
116
171
|
oldestStoredAt = Math.min(oldestStoredAt ?? email.storedAt, email.storedAt);
|
|
117
172
|
}
|
|
@@ -122,6 +177,7 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
|
|
|
122
177
|
}
|
|
123
178
|
return {
|
|
124
179
|
wanted: wanted.sort((a, b) => a - b),
|
|
180
|
+
sorted: new Map([...sorted].filter(([, one]) => one.verdict === "excerpt")),
|
|
125
181
|
oldestStoredAt,
|
|
126
182
|
cursor: cursorOf(batch.at(-1)?.id ?? mark.throughId, now),
|
|
127
183
|
more: newer.length > batch.length,
|
|
@@ -132,17 +188,17 @@ const examine = async (store, mailbox, holder, { now = Date.now(), limit = MARK_
|
|
|
132
188
|
};
|
|
133
189
|
/**
|
|
134
190
|
* What waits for the holder in every stored mailbox, read only: what a schedule asks before it starts the holder's
|
|
135
|
-
* agent, so a run with nothing new costs no model call.
|
|
136
|
-
* do not count.
|
|
191
|
+
* agent, so a run with nothing new costs no model call. Newsletters, envelopes, drafts, suspicious senders and partial
|
|
192
|
+
* mail do not count; other bulk mail counts as the excerpt the holder gets, unless the triage gives none.
|
|
137
193
|
*/
|
|
138
|
-
export const mailWaiting = async (store, holder, now = Date.now()) => {
|
|
194
|
+
export const mailWaiting = async (store, holder, now = Date.now(), triage = BULK_TRIAGE_DEFAULTS) => {
|
|
139
195
|
const waiting = { unmarked: false, emails: 0, oldestStoredAt: null };
|
|
140
196
|
for (const mailbox of await store.accounts.accounts()) {
|
|
141
197
|
if (mailbox.provider !== "email")
|
|
142
198
|
continue;
|
|
143
199
|
let after;
|
|
144
200
|
for (;;) {
|
|
145
|
-
const read = await examine(store, mailbox, holder, { now, ...(after === undefined ? {} : { after }) });
|
|
201
|
+
const read = await examine(store, mailbox, holder, { now, triage, ...(after === undefined ? {} : { after }) });
|
|
146
202
|
if (!read.mark) {
|
|
147
203
|
waiting.unmarked = true;
|
|
148
204
|
break;
|
|
@@ -159,17 +215,17 @@ export const mailWaiting = async (store, holder, now = Date.now()) => {
|
|
|
159
215
|
return waiting;
|
|
160
216
|
};
|
|
161
217
|
/**
|
|
162
|
-
* Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip —
|
|
163
|
-
* envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
|
|
218
|
+
* Moves the holder's mark, in every stored mailbox, over the mail at its head that it would only skip — newsletters,
|
|
219
|
+
* bulk mail the triage gives no excerpt of, envelopes, drafts, suspicious senders — as the agent would have acknowledged it, so a quiet holder's next check
|
|
164
220
|
* does not read it all again. Suspicious senders' emails stay on the mark to be offered again. It stops at the first
|
|
165
221
|
* email the holder reads. A store busy with a sync is left alone: the next check moves it.
|
|
166
222
|
*/
|
|
167
|
-
export const advancePastSkipped = async (store, holder, now = Date.now()) => {
|
|
223
|
+
export const advancePastSkipped = async (store, holder, now = Date.now(), triage = BULK_TRIAGE_DEFAULTS) => {
|
|
168
224
|
for (const mailbox of await store.accounts.accounts()) {
|
|
169
225
|
if (mailbox.provider !== "email")
|
|
170
226
|
continue;
|
|
171
227
|
for (;;) {
|
|
172
|
-
const read = await examine(store, mailbox, holder, { now });
|
|
228
|
+
const read = await examine(store, mailbox, holder, { now, triage });
|
|
173
229
|
if (!read.mark || read.wanted.length > 0 || read.cursor === cursorOf(read.mark.throughId, now))
|
|
174
230
|
break;
|
|
175
231
|
try {
|
|
@@ -260,3 +316,89 @@ export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date
|
|
|
260
316
|
await store.sync.setSyncState(mailbox, key, JSON.stringify(next));
|
|
261
317
|
return { holder, ...next, updatedAt: now };
|
|
262
318
|
};
|
|
319
|
+
/**
|
|
320
|
+
* Moves the holder's mark back, so its next reviews give it that mail again: past every email received since
|
|
321
|
+
* `since`, or past the last `last` emails it was given. The owner's alone: a holder only ever moves its own mark
|
|
322
|
+
* forward. The id is the store's insert order, so an older email stored after the earliest one picked is read again too;
|
|
323
|
+
* the answer counts them all.
|
|
324
|
+
*/
|
|
325
|
+
export const rewindMark = async (store, mailbox, holder, to, { now = Date.now(), dry = false } = {}) => {
|
|
326
|
+
const key = keyOf(holder);
|
|
327
|
+
const account = await accountOf(store, mailbox);
|
|
328
|
+
if (!account)
|
|
329
|
+
throw new CliError("not_found", `no mail of ${mailbox.account} is stored`);
|
|
330
|
+
const mark = await markOf(store, mailbox, holder);
|
|
331
|
+
if (!mark)
|
|
332
|
+
throw new CliError("not_found", `${holder} has no mark in ${mailbox.account}: its first review starts it`, {
|
|
333
|
+
reason: "mail.no-mark",
|
|
334
|
+
});
|
|
335
|
+
if ("last" in to && (!Number.isSafeInteger(to.last) || to.last < 1))
|
|
336
|
+
throw new CliError("validation_error", "--last takes a whole number of emails, 1 or more");
|
|
337
|
+
if ("since" in to && !Number.isFinite(to.since))
|
|
338
|
+
throw new CliError("validation_error", "--since-time takes a time");
|
|
339
|
+
const given = (email) => {
|
|
340
|
+
const said = verdict(email, now);
|
|
341
|
+
return said === "read" || said === "held" || said === "bulk";
|
|
342
|
+
};
|
|
343
|
+
const between = [];
|
|
344
|
+
let through = mark.throughId;
|
|
345
|
+
let before = mark.throughId + 1;
|
|
346
|
+
for (let counted = 0;;) {
|
|
347
|
+
const page = await store.mail.emailsBefore(account.id, before, MARK_BATCH);
|
|
348
|
+
if (page.length === 0) {
|
|
349
|
+
if ("last" in to && between.length > 0)
|
|
350
|
+
through = 0;
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
let done = false;
|
|
354
|
+
for (const email of page) {
|
|
355
|
+
if ("last" in to) {
|
|
356
|
+
between.push(email);
|
|
357
|
+
if (given(email))
|
|
358
|
+
counted += 1;
|
|
359
|
+
if (counted === to.last) {
|
|
360
|
+
through = email.id - 1;
|
|
361
|
+
done = true;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// An email is stored after it arrives, so once stored before the time, none further back arrived after it.
|
|
367
|
+
if (email.storedAt < to.since - CLOCK_SLACK_MS) {
|
|
368
|
+
done = true;
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
between.push(email);
|
|
372
|
+
if ((email.at ?? email.storedAt) >= to.since)
|
|
373
|
+
through = email.id - 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (done)
|
|
377
|
+
break;
|
|
378
|
+
before = page.at(-1)?.id ?? 0;
|
|
379
|
+
}
|
|
380
|
+
const moved = between.filter((email) => email.id > through);
|
|
381
|
+
const count = (pick) => moved.filter((email) => pick(verdict(email, now))).length;
|
|
382
|
+
const answer = {
|
|
383
|
+
fromId: mark.throughId,
|
|
384
|
+
again: count((said) => said === "read" || said === "held"),
|
|
385
|
+
bulk: count((said) => said === "bulk"),
|
|
386
|
+
skipped: count((said) => said !== "read" && said !== "held" && said !== "bulk"),
|
|
387
|
+
dry,
|
|
388
|
+
};
|
|
389
|
+
if (through >= mark.throughId)
|
|
390
|
+
return { mark, ...answer };
|
|
391
|
+
const [last] = through > 0 ? await store.mail.emailsBefore(account.id, through + 1, 1) : [];
|
|
392
|
+
const at = last?.id === through ? last.at : null;
|
|
393
|
+
const next = {
|
|
394
|
+
throughId: through,
|
|
395
|
+
throughAt: at,
|
|
396
|
+
held: mark.held.filter((id) => id <= through),
|
|
397
|
+
suspicious: mark.suspicious.filter((id) => id <= through),
|
|
398
|
+
read: mark.read,
|
|
399
|
+
skipped: mark.skipped,
|
|
400
|
+
};
|
|
401
|
+
if (!dry)
|
|
402
|
+
await store.sync.setSyncState(mailbox, key, JSON.stringify(next));
|
|
403
|
+
return { mark: { holder, ...next, updatedAt: dry ? mark.updatedAt : now }, ...answer };
|
|
404
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail sent to many is often an order, an invoice or a site's lead form that carries List-Unsubscribe. This tells a
|
|
3
|
+
* newsletter from it by words in the subject and the first lines, so a holder can leave out only what is surely a
|
|
4
|
+
* newsletter and give the rest to its agent as an excerpt. No model: it runs on every read past a mark.
|
|
5
|
+
*/
|
|
6
|
+
/** Words that say "advertising"; each matches at the start of a word, any case. */
|
|
7
|
+
export declare const NEWSLETTER_WORDS: readonly ["скидк", "акци", "распродаж", "вебинар", "рассылк", "промокод", "спецпредложени", "дайджест", "новинк", "подборк", "кешбэк", "кэшбэк", "только сегодня", "успейте", "чёрная пятница", "черная пятница", "выгодн", "newsletter", "unsubscribe", "% off", "sale", "discount", "webinar", "digest", "weekly", "promo", "deal", "limited time", "free shipping", "new arrivals", "black friday", "cyber monday", "coupon", "save up to", "last chance", "don't miss", "descuento", "oferta", "rebaja", "boletín", "seminario web", "novedades", "promoción", "no te pierdas", "últimos días"];
|
|
8
|
+
/** Parts of a sender's address that say "a newsletter": `news@…`, `newsletter.example.com`, `….substack.com`. */
|
|
9
|
+
export declare const NEWSLETTER_SENDERS: readonly ["news", "newsletter", "newsletters", "digest", "weekly", "promo", "marketing", "offers", "deals", "sale", "substack.com"];
|
|
10
|
+
/** Words that say "this is about something of yours"; one of them keeps the email for the agent. */
|
|
11
|
+
export declare const ORDER_WORDS: readonly ["заказ", "счёт", "счет на", "счета", "оплат", "квитанци", "кассовый чек", "электронный чек", "заявк", "форма", "доставк", "трек", "договор", "подтвержд", "код подтверждения", "код для входа", "пароль", "order", "invoice", "receipt", "payment", "shipped", "delivery", "tracking", "booking", "reservation", "confirm", "your account", "verification", "password", "refund", "statement", "pedido", "factura", "recibo", "pago", "envío", "reserva", "confirmación", "contraseña"];
|
|
12
|
+
export interface NewsletterCues {
|
|
13
|
+
newsletterWords: readonly string[];
|
|
14
|
+
newsletterSenders: readonly string[];
|
|
15
|
+
orderWords: readonly string[];
|
|
16
|
+
/** The score from which an email is surely a newsletter and left out. */
|
|
17
|
+
threshold: number;
|
|
18
|
+
/** How much of the email's own text is read, and shown as the excerpt. */
|
|
19
|
+
excerptChars: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const NEWSLETTER_DEFAULTS: NewsletterCues;
|
|
22
|
+
export interface BulkSorted {
|
|
23
|
+
/** `newsletter` is left out; `excerpt` goes to the agent as the subject and the first lines. */
|
|
24
|
+
verdict: "newsletter" | "excerpt";
|
|
25
|
+
score: number;
|
|
26
|
+
/** What decided it: the newsletter signs, or what kept the email. */
|
|
27
|
+
cues: string[];
|
|
28
|
+
excerpt: string;
|
|
29
|
+
}
|
|
30
|
+
/** The first lines of an email's own text, at most `chars`, cut at a word. */
|
|
31
|
+
export declare const excerptOf: (text: string, chars: number) => string;
|
|
32
|
+
/**
|
|
33
|
+
* Built once per read, so the word lists compile once. A reply, an order word in the subject or an order's number
|
|
34
|
+
* keeps the email. Otherwise a newsletter word counts 2 in the subject and 1 in the first lines, a newsletter's
|
|
35
|
+
* address 2, an emoji in the subject and a browser or unsubscribe link at the top 1 each, and each order word in
|
|
36
|
+
* the first lines takes 2 off. Measured 2026-11-05 on 30 days of the owner's bulk mail: 99 of 100 sampled
|
|
37
|
+
* newsletter verdicts were newsletters, and about half of the newsletters were found.
|
|
38
|
+
*/
|
|
39
|
+
export declare const newsletterSorter: (cues?: NewsletterCues) => ({ subject, text, from }: {
|
|
40
|
+
subject: string | null;
|
|
41
|
+
text: string;
|
|
42
|
+
from: string | null;
|
|
43
|
+
}) => BulkSorted;
|
|
44
|
+
/** How a holder's read treats bulk mail. */
|
|
45
|
+
export interface BulkTriage {
|
|
46
|
+
/** `false` leaves every bulk email out, newsletter or not. */
|
|
47
|
+
excerpts: boolean;
|
|
48
|
+
/** At most this many excerpts in one read; the read ends before the next one, which comes with the next read. */
|
|
49
|
+
perRead: number;
|
|
50
|
+
cues: NewsletterCues;
|
|
51
|
+
}
|
|
52
|
+
export declare const BULK_TRIAGE_DEFAULTS: BulkTriage;
|