@wirecat/cli-core 0.471.0 → 0.472.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/errors/reasons.js +1 -0
  3. package/dist/mail/bulk.d.ts +7 -0
  4. package/dist/mail/bulk.js +3 -0
  5. package/dist/mail/facts.d.ts +9 -0
  6. package/dist/mail/facts.js +22 -0
  7. package/dist/mail/folders.d.ts +10 -0
  8. package/dist/mail/folders.js +13 -0
  9. package/dist/mail/gone.d.ts +20 -0
  10. package/dist/mail/gone.js +53 -0
  11. package/dist/mail/headers.d.ts +43 -0
  12. package/dist/mail/headers.js +88 -0
  13. package/dist/mail/index.d.ts +17 -0
  14. package/dist/mail/index.js +16 -0
  15. package/dist/mail/parse.d.ts +24 -0
  16. package/dist/mail/parse.js +79 -0
  17. package/dist/mail/presets.d.ts +30 -0
  18. package/dist/mail/presets.js +95 -0
  19. package/dist/mail/probe.d.ts +49 -0
  20. package/dist/mail/probe.js +81 -0
  21. package/dist/mail/resume.d.ts +61 -0
  22. package/dist/mail/resume.js +61 -0
  23. package/dist/mail/rule-input.d.ts +14 -0
  24. package/dist/mail/rule-input.js +32 -0
  25. package/dist/mail/rule-tags.d.ts +40 -0
  26. package/dist/mail/rule-tags.js +87 -0
  27. package/dist/mail/rules.d.ts +83 -0
  28. package/dist/mail/rules.js +108 -0
  29. package/dist/mail/score.d.ts +67 -0
  30. package/dist/mail/score.js +174 -0
  31. package/dist/mail/sender-scores.d.ts +30 -0
  32. package/dist/mail/sender-scores.js +96 -0
  33. package/dist/mail/source.d.ts +67 -0
  34. package/dist/mail/source.js +1 -0
  35. package/dist/mail/stored.d.ts +7 -0
  36. package/dist/mail/stored.js +36 -0
  37. package/dist/mail/sync.d.ts +91 -0
  38. package/dist/mail/sync.js +572 -0
  39. package/dist/mail/testing/contract.d.ts +20 -0
  40. package/dist/mail/testing/contract.js +106 -0
  41. package/dist/mail/testing/fake.d.ts +61 -0
  42. package/dist/mail/testing/fake.js +136 -0
  43. package/dist/mail/testing/index.d.ts +2 -0
  44. package/dist/mail/testing/index.js +2 -0
  45. package/package.json +12 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.472.0 — 28.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`./mail` and `./mail/testing` — the mail engine, moved from memo.** `MailSource` (the port a mail CLI's adapter
15
+ implements), `syncMail(source, store, options)` (resume points per folder, rules, deletions with a safety stop,
16
+ envelopes, attachment text, `index` for the caller's embedding), `scoreSenders`, the rules and the parser. The account
17
+ key `{ provider: "email", account: <address> }` and the sync-state keys (`mail_resume:<folder>`, `mail_folder_scope`,
18
+ `mail_coverage`, `mail_sender_scores`) are memo's, so a mailbox memo imported continues where it stopped. `./mail/testing`
19
+ has a Gmail-shaped in-memory source and the contract every adapter must pass. See `docs/dev/MAIL.md`. Mail is now
20
+ fetched by its own source tool, not by the knowledge tool: `TOOL-BOUNDARIES.md` and `STANDARD.md` say so.
21
+
10
22
  ## 0.471.0 — 27.10.2026
11
23
 
12
24
  ### Added
@@ -47,6 +47,7 @@ const OTHERS = [
47
47
  ["extraction.invalid-proposals", "invalid_response", "the model's memory proposals could not be used"],
48
48
  validation("extraction.text-too-long", "the text is too long for memory extraction"),
49
49
  validation("file.locked", "another process holds the file's lock"),
50
+ ["mail.source-failed", "provider_error", "the mail server refused or did not complete a request"],
50
51
  ["mirror.partial", "provider_error", "some mirrored files were not synced"],
51
52
  ["profile.wrong-account", "authentication_error", "the signed-in source account does not match this profile"],
52
53
  validation("push.too-many-files", "the folder holds more files than one push takes"),
@@ -0,0 +1,7 @@
1
+ /** Mail sent to many: its own mailbox, so search can leave it out. Not a folder, so it proves nothing about deletions. */
2
+ export declare const BULK = "bulk";
3
+ export declare const bulkBox: {
4
+ externalId: string;
5
+ name: string;
6
+ kind: string;
7
+ };
@@ -0,0 +1,3 @@
1
+ /** Mail sent to many: its own mailbox, so search can leave it out. Not a folder, so it proves nothing about deletions. */
2
+ export const BULK = "bulk";
3
+ export const bulkBox = { externalId: "memo:bulk", name: "bulk", kind: BULK };
@@ -0,0 +1,9 @@
1
+ import type { MailFacts, MailRule } from "./rules.js";
2
+ import type { DomainAge } from "./score.js";
3
+ import type { Listed } from "./source.js";
4
+ /**
5
+ * What a rule can know of each listed email. A score and "the owner wrote to this sender" come from the sender's
6
+ * whole group in this listing, so they are worked out only when a rule needs them; a run that lists only new mail
7
+ * judges them from that mail.
8
+ */
9
+ export declare const factsOf: (listed: Listed[], rules: readonly MailRule[], ages?: ReadonlyMap<string, DomainAge>) => ((item: Listed) => MailFacts);
@@ -0,0 +1,22 @@
1
+ import { groupKey, probeMail } from "./probe.js";
2
+ /**
3
+ * What a rule can know of each listed email. A score and "the owner wrote to this sender" come from the sender's
4
+ * whole group in this listing, so they are worked out only when a rule needs them; a run that lists only new mail
5
+ * judges them from that mail.
6
+ */
7
+ export const factsOf = (listed, rules, ages = new Map()) => {
8
+ const grouped = rules.some(({ match }) => match.score_at_least !== undefined || match.preset !== undefined);
9
+ if (!grouped)
10
+ return (item) => item;
11
+ const groups = new Map(probeMail(listed, Number.POSITIVE_INFINITY, ages).items.map((group) => [
12
+ groupKey({
13
+ ...(group.from === null ? {} : { from: group.from }),
14
+ ...(group.listId === null ? {} : { listId: group.listId }),
15
+ }),
16
+ group,
17
+ ]));
18
+ return (item) => {
19
+ const group = item.headers === undefined ? undefined : groups.get(groupKey(item.headers));
20
+ return group === undefined ? item : { ...item, score: group.score, replied: group.replied };
21
+ };
22
+ };
@@ -0,0 +1,10 @@
1
+ import type { MailSource } from "./source.js";
2
+ export declare const GMAIL_ALL_MAIL = "[Gmail]/All Mail";
3
+ /**
4
+ * The folders to read. Gmail names All Mail in the account's language, so it is found by its role; a source that
5
+ * cannot say, or a listing without it, falls back to the English name and the read reports a missing folder.
6
+ */
7
+ export declare const mailFolders: (account: {
8
+ mode?: string;
9
+ folders?: string[];
10
+ }, source?: Pick<MailSource, "folders">) => Promise<string[]>;
@@ -0,0 +1,13 @@
1
+ export const GMAIL_ALL_MAIL = "[Gmail]/All Mail";
2
+ /**
3
+ * The folders to read. Gmail names All Mail in the account's language, so it is found by its role; a source that
4
+ * cannot say, or a listing without it, falls back to the English name and the read reports a missing folder.
5
+ */
6
+ export const mailFolders = async (account, source) => {
7
+ if (account.folders)
8
+ return account.folders;
9
+ if (account.mode === "imap")
10
+ return ["INBOX"];
11
+ const all = await source?.folders?.().then(({ all }) => all, () => undefined);
12
+ return [all ?? GMAIL_ALL_MAIL];
13
+ };
@@ -0,0 +1,20 @@
1
+ import type { AccountKey, MessageStore } from "../store/index.js";
2
+ export interface Gone {
3
+ deleted: number;
4
+ /** Why stored entries missing at the source were left alone, when they were. */
5
+ skipped?: string;
6
+ }
7
+ /** Why `gone` of `candidates` is too large a share to delete, or `undefined` when it may be deleted. */
8
+ export declare const tooManyGone: (gone: number, candidates: number) => string | undefined;
9
+ /**
10
+ * What the store holds of this account, from `since` on, and the source no longer lists, was deleted
11
+ * there: its text goes. Wrongly dropping text cannot be undone and a missed deletion is caught on the
12
+ * next run, so an unusually large share missing is refused rather than applied — an unmounted folder or
13
+ * a half-answered listing looks exactly like that.
14
+ */
15
+ export declare const dropMissing: (store: MessageStore, key: AccountKey, present: {
16
+ has(id: string): boolean;
17
+ }, since?: string, eligible?: (entry: {
18
+ chatId: string;
19
+ id: string;
20
+ }) => Promise<boolean>) => Promise<Gone>;
@@ -0,0 +1,53 @@
1
+ const PAGE = 500;
2
+ const MAX_DELETED_SHARE = 0.2;
3
+ const MIN_DELETIONS_CAPPED = 5;
4
+ /** Why `gone` of `candidates` is too large a share to delete, or `undefined` when it may be deleted. */
5
+ export const tooManyGone = (gone, candidates) => gone >= MIN_DELETIONS_CAPPED && gone > candidates * MAX_DELETED_SHARE
6
+ ? `${gone} of ${candidates} stored entries are missing at the source — too many to trust; nothing deleted`
7
+ : undefined;
8
+ /**
9
+ * What the store holds of this account, from `since` on, and the source no longer lists, was deleted
10
+ * there: its text goes. Wrongly dropping text cannot be undone and a missed deletion is caught on the
11
+ * next run, so an unusually large share missing is refused rather than applied — an unmounted folder or
12
+ * a half-answered listing looks exactly like that.
13
+ */
14
+ export const dropMissing = async (store, key, present, since, eligible) => {
15
+ const stored = [];
16
+ for (let offset = 0;; offset += PAGE) {
17
+ const chats = await store.chats.chats(key, { limit: PAGE, offset });
18
+ for (const chat of chats.items) {
19
+ if (since !== undefined && chat.lastMessageAt !== null && chat.lastMessageAt < since)
20
+ continue;
21
+ for (let before;;) {
22
+ const page = await store.messages.messages(key, chat.id, {
23
+ limit: PAGE,
24
+ ...(since === undefined ? {} : { since }),
25
+ ...(before === undefined ? {} : { before }),
26
+ });
27
+ stored.push(...page.items.map(({ id }) => ({ chatId: chat.id, id })));
28
+ before = page.items[0]?.id;
29
+ if (!page.hasMore || before === undefined)
30
+ break;
31
+ }
32
+ }
33
+ if (!chats.hasMore)
34
+ break;
35
+ }
36
+ const candidates = [];
37
+ for (const entry of stored)
38
+ if (eligible === undefined || (await eligible(entry)))
39
+ candidates.push(entry);
40
+ const gone = candidates.filter(({ id }) => !present.has(id));
41
+ if (gone.length === 0)
42
+ return { deleted: 0 };
43
+ const refused = tooManyGone(gone.length, candidates.length);
44
+ if (refused)
45
+ return { deleted: 0, skipped: refused };
46
+ const byChat = new Map();
47
+ for (const { chatId, id } of gone)
48
+ byChat.set(chatId, [...(byChat.get(chatId) ?? []), id]);
49
+ let deleted = 0;
50
+ for (const [chatId, ids] of byChat)
51
+ deleted += await store.messages.markDeleted(key, ids, { chatId });
52
+ return { deleted };
53
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The header fields memo reads at list time: enough to group mail by sender, apply the owner's rules and keep
3
+ * an envelope of the mail a rule records without its text.
4
+ */
5
+ export declare const HEADER_FIELDS = "FROM TO CC SUBJECT LIST-ID LIST-UNSUBSCRIBE PRECEDENCE REPLY-TO AUTHENTICATION-RESULTS CONTENT-TYPE";
6
+ /** SPF, DKIM and DMARC as Gmail judged them when the email arrived; each absent when Gmail did not say. */
7
+ export interface AuthVerdicts {
8
+ spf?: string;
9
+ dkim?: string;
10
+ dmarc?: string;
11
+ }
12
+ export interface MailHeaders {
13
+ /** The sender's address, lower case; absent when From holds none. */
14
+ from?: string;
15
+ /** The sender's display name, decoded. */
16
+ fromName?: string;
17
+ subject?: string;
18
+ /** To and Cc addresses, lower case. */
19
+ to: string[];
20
+ listId?: string;
21
+ /** List-Id, List-Unsubscribe or `Precedence: bulk|list|junk`: mail sent to many, not written to one. */
22
+ bulk: boolean;
23
+ /** The first Reply-To address, lower case. */
24
+ replyTo?: string;
25
+ auth?: AuthVerdicts;
26
+ /** `Content-Type: multipart/mixed`: how mail with attachments is sent. A guess, not a parse of the body. */
27
+ attachments?: boolean;
28
+ }
29
+ export declare const unfold: (text: string) => string;
30
+ export declare const field: (unfolded: string, name: string) => string | undefined;
31
+ /**
32
+ * The verdicts in Gmail's own Authentication-Results. Only the copy Gmail stamps as mx.google.com counts: a sender
33
+ * can add one under any other name, and Gmail removes forged copies under its own (RFC 8601 §5).
34
+ */
35
+ export declare const authOf: (unfolded: string) => AuthVerdicts | undefined;
36
+ export declare const addressesOf: (value: string | undefined) => string[];
37
+ /** The header fields of one email, from its header text. */
38
+ export declare const headersOf: (text: string) => MailHeaders;
39
+ /**
40
+ * List-Id, the bulk-mail fields, Reply-To and Gmail's SPF, DKIM and DMARC verdicts of a whole email, kept on mail
41
+ * stored in full so a rule or a sender score can be worked out from the store later. Names are lower case.
42
+ */
43
+ export declare const mailHeadersOf: (raw: Uint8Array) => Record<string, string | boolean>;
@@ -0,0 +1,88 @@
1
+ import { decodeWords } from "postal-mime";
2
+ /**
3
+ * The header fields memo reads at list time: enough to group mail by sender, apply the owner's rules and keep
4
+ * an envelope of the mail a rule records without its text.
5
+ */
6
+ export const HEADER_FIELDS = "FROM TO CC SUBJECT LIST-ID LIST-UNSUBSCRIBE PRECEDENCE REPLY-TO AUTHENTICATION-RESULTS CONTENT-TYPE";
7
+ export const unfold = (text) => text.replace(/\r?\n[ \t]+/g, " ");
8
+ export const field = (unfolded, name) => new RegExp(`^${name}:[ \\t]*(.*?)\\r?$`, "im").exec(unfolded)?.[1];
9
+ const fieldsOf = (unfolded, name) => [...unfolded.matchAll(new RegExp(`^${name}:[ \\t]*(.*?)\\r?$`, "gim"))].map(([, value]) => value);
10
+ /**
11
+ * The verdicts in Gmail's own Authentication-Results. Only the copy Gmail stamps as mx.google.com counts: a sender
12
+ * can add one under any other name, and Gmail removes forged copies under its own (RFC 8601 §5).
13
+ */
14
+ export const authOf = (unfolded) => {
15
+ const stamped = fieldsOf(unfolded, "Authentication-Results").find((value) => /^mx\.google\.com\s*;/i.test(value));
16
+ if (stamped === undefined)
17
+ return undefined;
18
+ const results = [...stamped.matchAll(/\b(spf|dkim|dmarc)=([a-z]+)/gi)].map(([, method, result]) => [
19
+ method.toLowerCase(),
20
+ result.toLowerCase(),
21
+ ]);
22
+ const verdicts = {};
23
+ for (const [method, result] of results) {
24
+ const key = method;
25
+ // An email can carry several DKIM signatures; one that passes is enough.
26
+ if (verdicts[key] === undefined || result === "pass")
27
+ verdicts[key] = result;
28
+ }
29
+ return Object.keys(verdicts).length ? verdicts : undefined;
30
+ };
31
+ export const addressesOf = (value) => {
32
+ if (value === undefined)
33
+ return [];
34
+ const bracketed = [...value.matchAll(/<([^<>\s]+@[^<>\s]+)>/g)].map(([, address]) => address);
35
+ const found = bracketed.length ? bracketed : (value.match(/[^\s<>,;:"()]+@[^\s<>,;:"()]+/g) ?? []);
36
+ return found.map((address) => address.toLowerCase());
37
+ };
38
+ const nameOf = (value) => {
39
+ const before = /^\s*(.*?)\s*<[^<>]+>\s*$/.exec(value)?.[1];
40
+ const name = before?.replace(/^"(.*)"$/, "$1").trim();
41
+ return name ? decodeWords(name) : undefined;
42
+ };
43
+ const listIdOf = (raw) => raw === undefined ? undefined : (/<([^>]+)>/.exec(raw)?.[1] ?? raw).trim().toLowerCase() || undefined;
44
+ /** The header fields of one email, from its header text. */
45
+ export const headersOf = (text) => {
46
+ const unfolded = unfold(text);
47
+ const sender = field(unfolded, "From");
48
+ const [from] = addressesOf(sender);
49
+ const fromName = sender === undefined ? undefined : nameOf(sender);
50
+ const subject = field(unfolded, "Subject");
51
+ const listId = listIdOf(field(unfolded, "List-Id"));
52
+ const [replyTo] = addressesOf(field(unfolded, "Reply-To"));
53
+ const auth = authOf(unfolded);
54
+ const bulk = Boolean(listId) ||
55
+ field(unfolded, "List-Unsubscribe") !== undefined ||
56
+ /^(bulk|list|junk)\b/i.test(field(unfolded, "Precedence") ?? "");
57
+ return {
58
+ ...(from === undefined ? {} : { from }),
59
+ ...(fromName ? { fromName } : {}),
60
+ ...(subject === undefined ? {} : { subject: decodeWords(subject).trim() }),
61
+ to: [...addressesOf(field(unfolded, "To")), ...addressesOf(field(unfolded, "Cc"))],
62
+ ...(listId ? { listId } : {}),
63
+ bulk,
64
+ ...(replyTo === undefined ? {} : { replyTo }),
65
+ ...(auth === undefined ? {} : { auth }),
66
+ ...(/^\s*multipart\/mixed\b/i.test(field(unfolded, "Content-Type") ?? "") ? { attachments: true } : {}),
67
+ };
68
+ };
69
+ /**
70
+ * List-Id, the bulk-mail fields, Reply-To and Gmail's SPF, DKIM and DMARC verdicts of a whole email, kept on mail
71
+ * stored in full so a rule or a sender score can be worked out from the store later. Names are lower case.
72
+ */
73
+ export const mailHeadersOf = (raw) => {
74
+ const bytes = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
75
+ const end = [bytes.indexOf("\r\n\r\n"), bytes.indexOf("\n\n")].filter((at) => at !== -1);
76
+ const head = bytes.subarray(0, end.length ? Math.min(...end) : Math.min(bytes.length, 256 * 1024));
77
+ const unfolded = unfold(head.toString("utf8"));
78
+ const listId = listIdOf(field(unfolded, "List-Id"));
79
+ const precedence = field(unfolded, "Precedence")?.trim().toLowerCase();
80
+ const [replyTo] = addressesOf(field(unfolded, "Reply-To"));
81
+ return {
82
+ ...(listId ? { "list-id": listId } : {}),
83
+ ...(replyTo === undefined ? {} : { "reply-to": replyTo }),
84
+ ...authOf(unfolded),
85
+ ...(field(unfolded, "List-Unsubscribe") === undefined ? {} : { "list-unsubscribe": true }),
86
+ ...(precedence ? { precedence } : {}),
87
+ };
88
+ };
@@ -0,0 +1,17 @@
1
+ export { BULK, bulkBox } from "./bulk.js";
2
+ export { factsOf } from "./facts.js";
3
+ export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
4
+ export { dropMissing, type Gone, tooManyGone } from "./gone.js";
5
+ export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
6
+ export { type Address, htmlToText, looksLikeHtml, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
7
+ export { PRESET_NAMES, PRESETS, type PresetName, presetMatches } from "./presets.js";
8
+ export { domainsToAge, groupKey, type Probe, type ProbeGroup, probeMail, SUGGEST_RULE } from "./probe.js";
9
+ export { type FileStamp, FULL_PASS_MS, MBOX_RESUME, type MboxPoint, mergeRanges, parsePoint, type Ranges, type ResumePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
10
+ export { ruleInput, STORE_ACTION } from "./rule-input.js";
11
+ export { mapProposedTags, ruleTagger, settleRuleTags, type TagOutcome } from "./rule-tags.js";
12
+ export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, type MailAction, type MailFacts, type MailRule, type MailRuleMatch, matches, sameMatch, tagsOf, } from "./rules.js";
13
+ export { type DomainAge, FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, type Scored, type Signal, scoreGroup, WEIGHTS, } from "./score.js";
14
+ export { DEMOTE_AT, type DomainAges, demotedSenders, type LookupResult, SCORE_VERSION, scoreSenders, } from "./sender-scores.js";
15
+ export type { Listed, MailSource, Searched } from "./source.js";
16
+ export { listedOf, type StoredMail } from "./stored.js";
17
+ export { MAX_ATTACHMENT_CHARS, type MailAccount, type MailIndexed, type MailSyncOptions, type MailSyncResult, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.js";
@@ -0,0 +1,16 @@
1
+ export { BULK, bulkBox } from "./bulk.js";
2
+ export { factsOf } from "./facts.js";
3
+ export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
4
+ export { dropMissing, tooManyGone } from "./gone.js";
5
+ export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
6
+ export { htmlToText, looksLikeHtml, MAX_BODY_CHARS, parseMail, } from "./parse.js";
7
+ export { PRESET_NAMES, PRESETS, presetMatches } from "./presets.js";
8
+ export { domainsToAge, groupKey, probeMail, SUGGEST_RULE } from "./probe.js";
9
+ export { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
10
+ export { ruleInput, STORE_ACTION } from "./rule-input.js";
11
+ export { mapProposedTags, ruleTagger, settleRuleTags } from "./rule-tags.js";
12
+ export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, matches, sameMatch, tagsOf, } from "./rules.js";
13
+ export { FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, scoreGroup, WEIGHTS, } from "./score.js";
14
+ export { DEMOTE_AT, demotedSenders, SCORE_VERSION, scoreSenders, } from "./sender-scores.js";
15
+ export { listedOf } from "./stored.js";
16
+ export { MAX_ATTACHMENT_CHARS, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.js";
@@ -0,0 +1,24 @@
1
+ export interface Address {
2
+ email: string;
3
+ name: string | null;
4
+ }
5
+ export interface ParsedMail {
6
+ subject: string | null;
7
+ from: Address | null;
8
+ to: Address[];
9
+ cc: Address[];
10
+ bcc: Address[];
11
+ attachments: {
12
+ id: number;
13
+ name: string;
14
+ mime: string | null;
15
+ bytes: Uint8Array | null;
16
+ }[];
17
+ text: string;
18
+ }
19
+ export declare const MAX_BODY_CHARS = 1000000;
20
+ export declare const htmlToText: (html: string) => string;
21
+ /** Some senders put their HTML in the text/plain part; its first tag gives it away. */
22
+ export declare const looksLikeHtml: (text: string) => boolean;
23
+ /** A raw RFC 5322 message, as IMAP's `BODY[]` holds it. */
24
+ export declare const parseMail: (raw: Uint8Array, maxBodyChars?: number) => Promise<ParsedMail>;
@@ -0,0 +1,79 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import PostalMime, {} from "postal-mime";
3
+ // The store cuts a body into pieces itself; this only guards against a pathological one, like a pasted log.
4
+ export const MAX_BODY_CHARS = 1_000_000;
5
+ const addresses = (list) => list
6
+ .flatMap((entry) => entry.group ?? [entry])
7
+ .flatMap(({ address, name }) => (address ? [{ email: address.toLowerCase(), name: name || null }] : []));
8
+ const HIDDEN_OPEN = /<(script|style)/gi;
9
+ const HIDDEN_CLOSE = { script: /<\/script>/gi, style: /<\/style>/gi };
10
+ const ENTITIES = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" };
11
+ // `<script[\s\S]*?</script>` as one regex rescans to the end from every unclosed opener; a tag found
12
+ // unclosed once stays unclosed for every later opener, so each close is searched for at most once.
13
+ const dropHidden = (html) => {
14
+ const unclosed = new Set();
15
+ let out = "";
16
+ let kept = 0;
17
+ for (const open of html.matchAll(HIDDEN_OPEN)) {
18
+ const name = open[1].toLowerCase();
19
+ if (open.index < kept || unclosed.has(name))
20
+ continue;
21
+ const close = HIDDEN_CLOSE[name];
22
+ close.lastIndex = open.index + open[0].length;
23
+ const end = close.exec(html);
24
+ if (!end) {
25
+ unclosed.add(name);
26
+ continue;
27
+ }
28
+ out += html.slice(kept, open.index);
29
+ kept = end.index + end[0].length;
30
+ }
31
+ return out + html.slice(kept);
32
+ };
33
+ // Past the last `>` no tag can close, yet `[^>]+` would rescan to the end from every `<` there: quadratic on `<<<<…`.
34
+ const stripTags = (html) => {
35
+ const end = html.lastIndexOf(">") + 1;
36
+ return html.slice(0, end).replace(/<[^>]+>/g, "") + html.slice(end);
37
+ };
38
+ // Comments go first: a tag inside one, like a commented-out link, would leave its text and a stray `-->` behind.
39
+ const dropComments = (html) => html.replace(/<!--[\s\S]*?(-->|$)/g, "");
40
+ export const htmlToText = (html) => stripTags(dropHidden(dropComments(html)).replace(/<br\s*\/?>|<\/(p|div|li|tr|h\d)>/gi, "\n"))
41
+ // One pass, so `&amp;lt;` stays the text `&lt;` instead of being decoded twice into `<`.
42
+ .replace(/&(nbsp|amp|lt|gt|quot|#39);/g, (_entity, name) => ENTITIES[name])
43
+ // From the start of a run only: `[ \t]+\n` alone rescans a long run of spaces from each of them.
44
+ .replace(/(?<![ \t])[ \t]+\n/g, "\n")
45
+ .replace(/\n{3,}/g, "\n\n")
46
+ .trim();
47
+ /** Some senders put their HTML in the text/plain part; its first tag gives it away. */
48
+ export const looksLikeHtml = (text) => /^\s*<(!doctype\s+html|html|head|body|table|div|center|meta|style)\b/i.test(text);
49
+ const bytesOf = (content) => typeof content === "string"
50
+ ? new TextEncoder().encode(content)
51
+ : content instanceof Uint8Array
52
+ ? content
53
+ : new Uint8Array(content);
54
+ /** A raw RFC 5322 message, as IMAP's `BODY[]` holds it. */
55
+ export const parseMail = async (raw, maxBodyChars = MAX_BODY_CHARS) => {
56
+ let email;
57
+ try {
58
+ email = await PostalMime.parse(raw);
59
+ }
60
+ catch (error) {
61
+ throw new CliError("invalid_response", `unreadable email: ${error instanceof Error ? error.message : error}`);
62
+ }
63
+ const plain = email.text?.trim() ?? "";
64
+ const text = plain && !looksLikeHtml(plain) ? plain : htmlToText(email.html ?? plain);
65
+ return {
66
+ subject: email.subject ?? null,
67
+ from: addresses(email.from === undefined ? [] : [email.from])[0] ?? null,
68
+ to: addresses(email.to ?? []),
69
+ cc: addresses(email.cc ?? []),
70
+ bcc: addresses(email.bcc ?? []),
71
+ attachments: email.attachments.slice(0, 50).map((part, id) => ({
72
+ id,
73
+ name: (part.filename ?? `attachment-${id}`).slice(0, 200),
74
+ mime: part.mimeType || null,
75
+ bytes: bytesOf(part.content),
76
+ })),
77
+ text: text.replace(/\r\n?/g, "\n").slice(0, maxBodyChars),
78
+ };
79
+ };
@@ -0,0 +1,30 @@
1
+ import type { MailFacts } from "./rules.js";
2
+ /**
3
+ * Kinds of mail every mailbox gets, matched by who sends them and how, not by a list of the owner's senders. Mail
4
+ * from someone the owner wrote to never matches a preset, so a real conversation is never caught by one.
5
+ */
6
+ export declare const PRESETS: {
7
+ readonly social: {
8
+ readonly about: "notifications from social networks";
9
+ readonly domains: readonly ["facebookmail.com", "facebook.com", "linkedin.com", "twitter.com", "x.com", "instagram.com", "vk.com", "ok.ru", "quora.com", "redditmail.com", "pinterest.com", "tiktok.com", "meetup.com", "tumblr.com", "youtube.com"];
10
+ };
11
+ readonly newsletters: {
12
+ readonly about: "mailing lists with an unsubscribe link: List-Id and List-Unsubscribe, or a newsletter service";
13
+ readonly domains: readonly ["substack.com", "mcsv.net", "mailchimpapp.net", "sendinblue.com", "beehiiv.com", "convertkit.com"];
14
+ };
15
+ readonly developer: {
16
+ readonly about: "code hosting, CI, package and error-tracker notifications";
17
+ readonly domains: readonly ["github.com", "gitlab.com", "bitbucket.org", "atlassian.net", "circleci.com", "travis-ci.com", "rollbar.com", "sentry.io", "vercel.com", "netlify.com", "npmjs.com", "docker.com", "heroku.com", "codecov.io", "libraries.io"];
18
+ };
19
+ readonly shopping: {
20
+ readonly about: "shops and promotions, and what Gmail files under Promotions";
21
+ readonly domains: readonly ["aliexpress.com", "aliexpress.ru", "ebay.com", "ozon.ru", "wildberries.ru", "steampowered.com", "booking.com", "zalando.com", "etsy.com", "ikea.com"];
22
+ };
23
+ readonly alerts: {
24
+ readonly about: "monitoring and uptime alerts";
25
+ readonly domains: readonly ["uptimerobot.com", "pingdom.com", "statuscake.com", "pagerduty.com", "opsgenie.com", "newrelic.com", "datadoghq.com", "betteruptime.com", "healthchecks.io"];
26
+ };
27
+ };
28
+ export type PresetName = keyof typeof PRESETS;
29
+ export declare const PRESET_NAMES: PresetName[];
30
+ export declare const presetMatches: (name: PresetName, facts: MailFacts) => boolean;
@@ -0,0 +1,95 @@
1
+ import { registrableOf } from "./score.js";
2
+ /**
3
+ * Kinds of mail every mailbox gets, matched by who sends them and how, not by a list of the owner's senders. Mail
4
+ * from someone the owner wrote to never matches a preset, so a real conversation is never caught by one.
5
+ */
6
+ export const PRESETS = {
7
+ social: {
8
+ about: "notifications from social networks",
9
+ domains: [
10
+ "facebookmail.com",
11
+ "facebook.com",
12
+ "linkedin.com",
13
+ "twitter.com",
14
+ "x.com",
15
+ "instagram.com",
16
+ "vk.com",
17
+ "ok.ru",
18
+ "quora.com",
19
+ "redditmail.com",
20
+ "pinterest.com",
21
+ "tiktok.com",
22
+ "meetup.com",
23
+ "tumblr.com",
24
+ "youtube.com",
25
+ ],
26
+ },
27
+ newsletters: {
28
+ about: "mailing lists with an unsubscribe link: List-Id and List-Unsubscribe, or a newsletter service",
29
+ domains: ["substack.com", "mcsv.net", "mailchimpapp.net", "sendinblue.com", "beehiiv.com", "convertkit.com"],
30
+ },
31
+ developer: {
32
+ about: "code hosting, CI, package and error-tracker notifications",
33
+ domains: [
34
+ "github.com",
35
+ "gitlab.com",
36
+ "bitbucket.org",
37
+ "atlassian.net",
38
+ "circleci.com",
39
+ "travis-ci.com",
40
+ "rollbar.com",
41
+ "sentry.io",
42
+ "vercel.com",
43
+ "netlify.com",
44
+ "npmjs.com",
45
+ "docker.com",
46
+ "heroku.com",
47
+ "codecov.io",
48
+ "libraries.io",
49
+ ],
50
+ },
51
+ shopping: {
52
+ about: "shops and promotions, and what Gmail files under Promotions",
53
+ domains: [
54
+ "aliexpress.com",
55
+ "aliexpress.ru",
56
+ "ebay.com",
57
+ "ozon.ru",
58
+ "wildberries.ru",
59
+ "steampowered.com",
60
+ "booking.com",
61
+ "zalando.com",
62
+ "etsy.com",
63
+ "ikea.com",
64
+ ],
65
+ },
66
+ alerts: {
67
+ about: "monitoring and uptime alerts",
68
+ domains: [
69
+ "uptimerobot.com",
70
+ "pingdom.com",
71
+ "statuscake.com",
72
+ "pagerduty.com",
73
+ "opsgenie.com",
74
+ "newrelic.com",
75
+ "datadoghq.com",
76
+ "betteruptime.com",
77
+ "healthchecks.io",
78
+ ],
79
+ },
80
+ };
81
+ export const PRESET_NAMES = Object.keys(PRESETS);
82
+ export const presetMatches = (name, facts) => {
83
+ const { headers } = facts;
84
+ const sender = headers?.from;
85
+ if (sender === undefined || facts.replied)
86
+ return false;
87
+ const domain = registrableOf(sender);
88
+ if (PRESETS[name].domains.includes(domain))
89
+ return true;
90
+ if (name === "newsletters")
91
+ return headers?.listId !== undefined && headers.bulk;
92
+ if (name === "shopping")
93
+ return facts.labels?.includes("Promotions") ?? false;
94
+ return false;
95
+ };
@@ -0,0 +1,49 @@
1
+ import { type PresetName } from "./presets.js";
2
+ import { type DomainAge, type Signal } from "./score.js";
3
+ import type { Listed } from "./source.js";
4
+ export declare const SUGGEST_RULE: string;
5
+ export interface ProbeGroup {
6
+ /** The sender; for a list, the first sender seen. */
7
+ from: string | null;
8
+ listId: string | null;
9
+ emails: number;
10
+ bytes: number;
11
+ /** Share of emails not read; null when the source does not say. */
12
+ unread: number | null;
13
+ /** Share of emails marked as list or bulk mail. */
14
+ bulk: number;
15
+ firstYear: number;
16
+ lastYear: number;
17
+ /** The owner sent mail to one of the group's senders. */
18
+ replied: boolean;
19
+ suggest: "skip" | null;
20
+ /** How much the group looks like spam, a scam or a robot: the sum of its signals' weights (SCORE_RULE). */
21
+ score: number;
22
+ signals: Signal[];
23
+ }
24
+ export interface Probe {
25
+ emails: number;
26
+ bytes: number;
27
+ /** Mail the owner sent: not grouped, but its recipients tell who was replied to. */
28
+ sent: number;
29
+ /** Emails whose headers could not be read: not grouped. */
30
+ withoutHeaders: number;
31
+ groups: number;
32
+ items: ProbeGroup[];
33
+ /** How much mail each built-in preset would match: proposals the owner may turn into rules. */
34
+ presets: {
35
+ preset: PresetName;
36
+ emails: number;
37
+ groups: number;
38
+ bytes: number;
39
+ }[];
40
+ }
41
+ /** A list's mail is one group whoever sends it; other mail is grouped by sender. */
42
+ export declare const groupKey: (headers: {
43
+ from?: string;
44
+ listId?: string;
45
+ }) => string;
46
+ /** Groups mail by List-Id when it has one, else by sender address; largest groups first. */
47
+ export declare const probeMail: (items: Listed[], limit: number, ages?: ReadonlyMap<string, DomainAge>) => Probe;
48
+ /** The sender domains of the groups worth asking a registry about: not a big mailbox provider, never replied to. */
49
+ export declare const domainsToAge: (groups: ProbeGroup[], skip: ReadonlySet<string>) => string[];