@wirecat/cli-core 0.575.0 → 0.576.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 +17 -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 +1 -0
- package/dist/mail/cli/review.js +34 -13
- package/dist/mail/index.d.ts +2 -1
- package/dist/mail/index.js +1 -0
- package/dist/mail/marks.d.ts +19 -7
- package/dist/mail/marks.js +72 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.576.0 — 05.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`review --holder` gives an agent the bulk mail that is not surely a newsletter.** Shops, banks and site forms put
|
|
15
|
+
List-Unsubscribe on orders, invoices and lead forms too, and the holder read left all of it out. Now
|
|
16
|
+
`newsletterSorter` scores each bulk email by words in its subject and first lines (Russian, English, Spanish), its
|
|
17
|
+
sender's address, an emoji or an unsubscribe link: a newsletter is left out and counted under `missing` as
|
|
18
|
+
`newsletter`; an order word in the subject, an order's or a request's number, or a reply keeps it. A kept email
|
|
19
|
+
comes after the other mail, as its subject and first lines, with `email.bulk: true` and `email.excerpt_only: true`;
|
|
20
|
+
`messages show <ref>` reads it whole. At most 30 excerpts a read; the read then ends early and the next gives the
|
|
21
|
+
rest. `mailWaiting` and `advancePastSkipped` count and stop at excerpts the same way. A host sets it from its config
|
|
22
|
+
file: `MAIL_BULK_SETTINGS` (`mail_bulk_excerpts`, `mail_bulk_excerpts_per_read`, `mail_bulk_excerpt_chars`,
|
|
23
|
+
`mail_bulk_newsletter_score`, `mail_bulk_newsletter_words`, `mail_bulk_newsletter_senders`, `mail_bulk_order_words`),
|
|
24
|
+
read by `bulkTriageOf` into `MailReads.bulkTriage`, and passed to `mailWaiting` and `advancePastSkipped` as their
|
|
25
|
+
new last argument; without it the defaults apply.
|
|
26
|
+
|
|
10
27
|
## 0.575.0 — 05.11.2026
|
|
11
28
|
|
|
12
29
|
### Added
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DeclaredSetting } from "../../cli/setting-defaults.js";
|
|
2
|
+
import { type BulkTriage } from "../newsletter.js";
|
|
3
|
+
/** The bulk-mail settings a host that mounts `review --holder` writes into its config file's top level (WC-CMD-30). */
|
|
4
|
+
export declare const MAIL_BULK_SETTINGS: readonly DeclaredSetting[];
|
|
5
|
+
/** `MAIL_BULK_SETTINGS` as the config file gives them; a missing or unusable value is left to the default. */
|
|
6
|
+
export declare const bulkTriageOf: (config: Record<string, unknown>) => BulkTriage;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { BULK_TRIAGE_DEFAULTS, NEWSLETTER_DEFAULTS, NEWSLETTER_SENDERS, NEWSLETTER_WORDS, ORDER_WORDS, } from "../newsletter.js";
|
|
2
|
+
const listed = (words) => words.join(", ");
|
|
3
|
+
/** The bulk-mail settings a host that mounts `review --holder` writes into its config file's top level (WC-CMD-30). */
|
|
4
|
+
export const MAIL_BULK_SETTINGS = [
|
|
5
|
+
{
|
|
6
|
+
key: "mail_bulk_excerpts",
|
|
7
|
+
default: BULK_TRIAGE_DEFAULTS.excerpts,
|
|
8
|
+
description: "review --holder gives an agent bulk mail that is not surely a newsletter as its subject and first lines; " +
|
|
9
|
+
"false leaves all bulk mail out",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
key: "mail_bulk_excerpts_per_read",
|
|
13
|
+
default: BULK_TRIAGE_DEFAULTS.perRead,
|
|
14
|
+
description: "the most bulk excerpts one review --holder gives; the rest come with the next read",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
key: "mail_bulk_excerpt_chars",
|
|
18
|
+
default: NEWSLETTER_DEFAULTS.excerptChars,
|
|
19
|
+
description: "how many characters of a bulk email's own text an excerpt shows",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: "mail_bulk_newsletter_score",
|
|
23
|
+
default: NEWSLETTER_DEFAULTS.threshold,
|
|
24
|
+
description: "bulk mail scoring this or more is left out as a newsletter: a newsletter word counts 2 in the subject, 1 in " +
|
|
25
|
+
"the first lines, a newsletter's address 2, an emoji in the subject 1, an unsubscribe link at the top 1, " +
|
|
26
|
+
"an order word in the first lines -2",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
key: "mail_bulk_newsletter_words",
|
|
30
|
+
default: listed(NEWSLETTER_WORDS),
|
|
31
|
+
description: "comma-separated words that say advertising; each matches at the start of a word, any case",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: "mail_bulk_newsletter_senders",
|
|
35
|
+
default: listed(NEWSLETTER_SENDERS),
|
|
36
|
+
description: "comma-separated parts of a sender's address (news, digest) or domains (substack.com) of newsletters",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: "mail_bulk_order_words",
|
|
40
|
+
default: listed(ORDER_WORDS),
|
|
41
|
+
description: "comma-separated words of orders, invoices and requests: one in the subject keeps a bulk email for the agent",
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
const wordsOf = (value) => typeof value === "string"
|
|
45
|
+
? value
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((word) => word.trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
: undefined;
|
|
50
|
+
const countOf = (value, least) => typeof value === "number" && Number.isSafeInteger(value) && value >= least ? value : undefined;
|
|
51
|
+
/** `MAIL_BULK_SETTINGS` as the config file gives them; a missing or unusable value is left to the default. */
|
|
52
|
+
export const bulkTriageOf = (config) => {
|
|
53
|
+
const { cues } = BULK_TRIAGE_DEFAULTS;
|
|
54
|
+
return {
|
|
55
|
+
excerpts: typeof config.mail_bulk_excerpts === "boolean" ? config.mail_bulk_excerpts : BULK_TRIAGE_DEFAULTS.excerpts,
|
|
56
|
+
perRead: countOf(config.mail_bulk_excerpts_per_read, 1) ?? BULK_TRIAGE_DEFAULTS.perRead,
|
|
57
|
+
cues: {
|
|
58
|
+
threshold: countOf(config.mail_bulk_newsletter_score, 1) ?? cues.threshold,
|
|
59
|
+
excerptChars: countOf(config.mail_bulk_excerpt_chars, 40) ?? cues.excerptChars,
|
|
60
|
+
newsletterWords: wordsOf(config.mail_bulk_newsletter_words) ?? cues.newsletterWords,
|
|
61
|
+
newsletterSenders: wordsOf(config.mail_bulk_newsletter_senders) ?? cues.newsletterSenders,
|
|
62
|
+
orderWords: wordsOf(config.mail_bulk_order_words) ?? cues.orderWords,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
};
|
package/dist/mail/cli/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import type { BulkTriage } from "../../mail/newsletter.js";
|
|
2
3
|
import { type MailServerAccess } from "../../mail/server-search.js";
|
|
3
4
|
import type { AppIdentity } from "../../ports/app.js";
|
|
4
5
|
import { type AccountKey, type MessageStore } from "../../store/store.js";
|
|
6
|
+
export { bulkTriageOf, MAIL_BULK_SETTINGS } from "./bulk-settings.js";
|
|
5
7
|
export { mailMark, markItem, markText } from "./marks.js";
|
|
6
8
|
export { MAIL_REVIEW_QUERY, MAIL_REVIEW_STEPS, mailReviewSteps } from "./review.js";
|
|
7
9
|
/**
|
|
@@ -37,6 +39,8 @@ export interface MailReads {
|
|
|
37
39
|
* With it, `messages show` reads an email whose attachment a sync's policy left unread before it answers.
|
|
38
40
|
*/
|
|
39
41
|
whole?: (command: Command, store: MessageStore, mailbox: AccountKey, emailId: string) => Promise<void>;
|
|
42
|
+
/** How `review --holder` treats bulk mail, from the host's config file (`MAIL_BULK_SETTINGS`); defaults without it. */
|
|
43
|
+
bulkTriage?: (command: Command) => BulkTriage;
|
|
40
44
|
}
|
|
41
45
|
export declare const THREAD_QUERY: {
|
|
42
46
|
readonly entity: "mail_thread";
|
package/dist/mail/cli/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { sourceKey } from "../../services/server-search.js";
|
|
|
21
21
|
import { queryFilter, THREAD_COLUMNS } from "../../store/store.js";
|
|
22
22
|
import { acknowledgeCommand } from "./marks.js";
|
|
23
23
|
import { mailReviewCommand } from "./review.js";
|
|
24
|
+
export { bulkTriageOf, MAIL_BULK_SETTINGS } from "./bulk-settings.js";
|
|
24
25
|
export { mailMark, markItem, markText } from "./marks.js";
|
|
25
26
|
export { MAIL_REVIEW_QUERY, MAIL_REVIEW_STEPS, mailReviewSteps } from "./review.js";
|
|
26
27
|
/** A locked or broken store must fail the command, not read as "nothing stored yet". */
|
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/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, mailWaiting, markOf, marksOf, readPastMark, 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
|
@@ -10,6 +10,7 @@ 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
12
|
export { acknowledgeMark, advancePastSkipped, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, mailWaiting, markOf, marksOf, readPastMark, } 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,17 +72,17 @@ 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.
|
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 {
|
|
@@ -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;
|
|
@@ -0,0 +1,189 @@
|
|
|
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 const NEWSLETTER_WORDS = [
|
|
8
|
+
"скидк",
|
|
9
|
+
"акци",
|
|
10
|
+
"распродаж",
|
|
11
|
+
"вебинар",
|
|
12
|
+
"рассылк",
|
|
13
|
+
"промокод",
|
|
14
|
+
"спецпредложени",
|
|
15
|
+
"дайджест",
|
|
16
|
+
"новинк",
|
|
17
|
+
"подборк",
|
|
18
|
+
"кешбэк",
|
|
19
|
+
"кэшбэк",
|
|
20
|
+
"только сегодня",
|
|
21
|
+
"успейте",
|
|
22
|
+
"чёрная пятница",
|
|
23
|
+
"черная пятница",
|
|
24
|
+
"выгодн",
|
|
25
|
+
"newsletter",
|
|
26
|
+
"unsubscribe",
|
|
27
|
+
"% off",
|
|
28
|
+
"sale",
|
|
29
|
+
"discount",
|
|
30
|
+
"webinar",
|
|
31
|
+
"digest",
|
|
32
|
+
"weekly",
|
|
33
|
+
"promo",
|
|
34
|
+
"deal",
|
|
35
|
+
"limited time",
|
|
36
|
+
"free shipping",
|
|
37
|
+
"new arrivals",
|
|
38
|
+
"black friday",
|
|
39
|
+
"cyber monday",
|
|
40
|
+
"coupon",
|
|
41
|
+
"save up to",
|
|
42
|
+
"last chance",
|
|
43
|
+
"don't miss",
|
|
44
|
+
"descuento",
|
|
45
|
+
"oferta",
|
|
46
|
+
"rebaja",
|
|
47
|
+
"boletín",
|
|
48
|
+
"seminario web",
|
|
49
|
+
"novedades",
|
|
50
|
+
"promoción",
|
|
51
|
+
"no te pierdas",
|
|
52
|
+
"últimos días",
|
|
53
|
+
];
|
|
54
|
+
/** Parts of a sender's address that say "a newsletter": `news@…`, `newsletter.example.com`, `….substack.com`. */
|
|
55
|
+
export const NEWSLETTER_SENDERS = [
|
|
56
|
+
"news",
|
|
57
|
+
"newsletter",
|
|
58
|
+
"newsletters",
|
|
59
|
+
"digest",
|
|
60
|
+
"weekly",
|
|
61
|
+
"promo",
|
|
62
|
+
"marketing",
|
|
63
|
+
"offers",
|
|
64
|
+
"deals",
|
|
65
|
+
"sale",
|
|
66
|
+
"substack.com",
|
|
67
|
+
];
|
|
68
|
+
/** Words that say "this is about something of yours"; one of them keeps the email for the agent. */
|
|
69
|
+
export const ORDER_WORDS = [
|
|
70
|
+
"заказ",
|
|
71
|
+
"счёт",
|
|
72
|
+
"счет на",
|
|
73
|
+
"счета",
|
|
74
|
+
"оплат",
|
|
75
|
+
"квитанци",
|
|
76
|
+
"кассовый чек",
|
|
77
|
+
"электронный чек",
|
|
78
|
+
"заявк",
|
|
79
|
+
"форма",
|
|
80
|
+
"доставк",
|
|
81
|
+
"трек",
|
|
82
|
+
"договор",
|
|
83
|
+
"подтвержд",
|
|
84
|
+
"код подтверждения",
|
|
85
|
+
"код для входа",
|
|
86
|
+
"пароль",
|
|
87
|
+
"order",
|
|
88
|
+
"invoice",
|
|
89
|
+
"receipt",
|
|
90
|
+
"payment",
|
|
91
|
+
"shipped",
|
|
92
|
+
"delivery",
|
|
93
|
+
"tracking",
|
|
94
|
+
"booking",
|
|
95
|
+
"reservation",
|
|
96
|
+
"confirm",
|
|
97
|
+
"your account",
|
|
98
|
+
"verification",
|
|
99
|
+
"password",
|
|
100
|
+
"refund",
|
|
101
|
+
"statement",
|
|
102
|
+
"pedido",
|
|
103
|
+
"factura",
|
|
104
|
+
"recibo",
|
|
105
|
+
"pago",
|
|
106
|
+
"envío",
|
|
107
|
+
"reserva",
|
|
108
|
+
"confirmación",
|
|
109
|
+
"contraseña",
|
|
110
|
+
];
|
|
111
|
+
/** A number an order, an invoice or a request is known by, after the word that names it. */
|
|
112
|
+
const REFERENCE_NUMBER = /(?<![\p{L}])(?:заказ|заявк|сч[её]т|квитанци|order|invoice|ticket|pedido|factura)\p{L}*\s*(?:№|#|n[o°]\.?)?\s?\d{3,}/iu;
|
|
113
|
+
/** The top of a newsletter: a link to read it in a browser, or to leave the list. */
|
|
114
|
+
const NEWSLETTER_TOP = /в браузере|веб-верси|view (?:it |this email )?(?:in|on) (?:your |a )?browser|unsubscribe|отписаться|darse de baja|ver en (?:el )?navegador/iu;
|
|
115
|
+
const PICTOGRAPH = /\p{Extended_Pictographic}/u;
|
|
116
|
+
/** A reply or a forward is a conversation, whatever it says. */
|
|
117
|
+
const REPLY = /^\s*(?:re|fw|fwd|aw|wg|ответ|отв|rv|res)\s*(?:\[\d+\])?\s*:/iu;
|
|
118
|
+
export const NEWSLETTER_DEFAULTS = {
|
|
119
|
+
newsletterWords: NEWSLETTER_WORDS,
|
|
120
|
+
newsletterSenders: NEWSLETTER_SENDERS,
|
|
121
|
+
orderWords: ORDER_WORDS,
|
|
122
|
+
threshold: 2,
|
|
123
|
+
excerptChars: 400,
|
|
124
|
+
};
|
|
125
|
+
const escaped = (word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
126
|
+
const matcherOf = (words) => {
|
|
127
|
+
const list = words.map((word) => word.trim().toLowerCase()).filter(Boolean);
|
|
128
|
+
if (list.length === 0)
|
|
129
|
+
return () => [];
|
|
130
|
+
const pattern = new RegExp(`(?<![\\p{L}\\p{N}])(?:${list.map(escaped).join("|")})`, "giu");
|
|
131
|
+
return (text) => [...new Set([...text.toLowerCase().matchAll(pattern)].map(([found]) => found))];
|
|
132
|
+
};
|
|
133
|
+
/** The first lines of an email's own text, at most `chars`, cut at a word. */
|
|
134
|
+
export const excerptOf = (text, chars) => {
|
|
135
|
+
const flat = text
|
|
136
|
+
.split("\n")
|
|
137
|
+
.map((line) => line.trim())
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join("\n");
|
|
140
|
+
if (flat.length <= chars)
|
|
141
|
+
return flat;
|
|
142
|
+
const cut = flat.slice(0, chars);
|
|
143
|
+
const space = cut.search(/\s\S*$/);
|
|
144
|
+
return `${space > chars / 2 ? cut.slice(0, space) : cut}…`;
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Built once per read, so the word lists compile once. A reply, an order word in the subject or an order's number
|
|
148
|
+
* keeps the email. Otherwise a newsletter word counts 2 in the subject and 1 in the first lines, a newsletter's
|
|
149
|
+
* address 2, an emoji in the subject and a browser or unsubscribe link at the top 1 each, and each order word in
|
|
150
|
+
* the first lines takes 2 off. Measured 2026-11-05 on 30 days of the owner's bulk mail: 99 of 100 sampled
|
|
151
|
+
* newsletter verdicts were newsletters, and about half of the newsletters were found.
|
|
152
|
+
*/
|
|
153
|
+
export const newsletterSorter = (cues = NEWSLETTER_DEFAULTS) => {
|
|
154
|
+
const advertising = matcherOf(cues.newsletterWords);
|
|
155
|
+
const ordered = matcherOf(cues.orderWords);
|
|
156
|
+
const senders = cues.newsletterSenders.map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
157
|
+
const fromNewsletter = (address) => {
|
|
158
|
+
const domain = address.split("@")[1] ?? "";
|
|
159
|
+
const parts = new Set(address.split(/[.@_+-]/));
|
|
160
|
+
return senders.some((one) => (one.includes(".") ? domain === one || domain.endsWith(`.${one}`) : parts.has(one)));
|
|
161
|
+
};
|
|
162
|
+
return ({ subject, text, from }) => {
|
|
163
|
+
const excerpt = excerptOf(text, cues.excerptChars);
|
|
164
|
+
const title = subject ?? "";
|
|
165
|
+
const number = REFERENCE_NUMBER.exec(`${title}\n${excerpt}`);
|
|
166
|
+
const kept = [...(REPLY.test(title) ? ["a reply"] : []), ...ordered(title), ...(number ? [number[0]] : [])];
|
|
167
|
+
if (kept.length > 0)
|
|
168
|
+
return { verdict: "excerpt", score: 0, cues: kept, excerpt };
|
|
169
|
+
const inTitle = advertising(title);
|
|
170
|
+
const signs = [
|
|
171
|
+
...inTitle.map((word) => [word, 2]),
|
|
172
|
+
...advertising(excerpt)
|
|
173
|
+
.filter((word) => !inTitle.includes(word))
|
|
174
|
+
.map((word) => [word, 1]),
|
|
175
|
+
...(PICTOGRAPH.test(title) ? [["an emoji in the subject", 1]] : []),
|
|
176
|
+
...(NEWSLETTER_TOP.test(excerpt) ? [["a browser or unsubscribe link", 1]] : []),
|
|
177
|
+
...(fromNewsletter((from ?? "").toLowerCase()) ? [["a newsletter's address", 2]] : []),
|
|
178
|
+
...ordered(excerpt).map((word) => [`-${word}`, -2]),
|
|
179
|
+
];
|
|
180
|
+
const score = signs.reduce((sum, [, weight]) => sum + weight, 0);
|
|
181
|
+
return {
|
|
182
|
+
verdict: score >= cues.threshold ? "newsletter" : "excerpt",
|
|
183
|
+
score,
|
|
184
|
+
cues: signs.map(([cue]) => cue),
|
|
185
|
+
excerpt,
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
export const BULK_TRIAGE_DEFAULTS = { excerpts: true, perRead: 30, cues: NEWSLETTER_DEFAULTS };
|
package/dist/mail/review.d.ts
CHANGED
|
@@ -18,8 +18,10 @@ export interface MailReviewOptions {
|
|
|
18
18
|
now?: number;
|
|
19
19
|
}
|
|
20
20
|
/** A review of mail: each thread a chat, each email a stored hit with its locator. */
|
|
21
|
+
/** `excerptOnly`: a bulk email a holder gets as its subject and first lines, its `text` cut to them. */
|
|
21
22
|
export type MailHit = StoredHit & {
|
|
22
23
|
marks?: EmailMarks;
|
|
24
|
+
excerptOnly?: true;
|
|
23
25
|
};
|
|
24
26
|
export interface MailReview extends Review {
|
|
25
27
|
chats: (Omit<ReviewChat, "messages"> & {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.576.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",
|