@wirecat/cli-core 0.470.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 (62) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/app-doctor-command.d.ts +107 -0
  3. package/dist/cli/app-doctor-command.js +101 -0
  4. package/dist/cli/app-mcp-command.d.ts +23 -0
  5. package/dist/cli/app-mcp-command.js +49 -0
  6. package/dist/cli/index.d.ts +4 -2
  7. package/dist/cli/index.js +2 -1
  8. package/dist/cli/messenger/mcp-command.d.ts +29 -3
  9. package/dist/cli/messenger/mcp-command.js +59 -39
  10. package/dist/cli/shared-command-exports.d.ts +348 -0
  11. package/dist/cli/shared-command-exports.js +4 -0
  12. package/dist/errors/reasons.js +1 -0
  13. package/dist/mail/bulk.d.ts +7 -0
  14. package/dist/mail/bulk.js +3 -0
  15. package/dist/mail/facts.d.ts +9 -0
  16. package/dist/mail/facts.js +22 -0
  17. package/dist/mail/folders.d.ts +10 -0
  18. package/dist/mail/folders.js +13 -0
  19. package/dist/mail/gone.d.ts +20 -0
  20. package/dist/mail/gone.js +53 -0
  21. package/dist/mail/headers.d.ts +43 -0
  22. package/dist/mail/headers.js +88 -0
  23. package/dist/mail/index.d.ts +17 -0
  24. package/dist/mail/index.js +16 -0
  25. package/dist/mail/parse.d.ts +24 -0
  26. package/dist/mail/parse.js +79 -0
  27. package/dist/mail/presets.d.ts +30 -0
  28. package/dist/mail/presets.js +95 -0
  29. package/dist/mail/probe.d.ts +49 -0
  30. package/dist/mail/probe.js +81 -0
  31. package/dist/mail/resume.d.ts +61 -0
  32. package/dist/mail/resume.js +61 -0
  33. package/dist/mail/rule-input.d.ts +14 -0
  34. package/dist/mail/rule-input.js +32 -0
  35. package/dist/mail/rule-tags.d.ts +40 -0
  36. package/dist/mail/rule-tags.js +87 -0
  37. package/dist/mail/rules.d.ts +83 -0
  38. package/dist/mail/rules.js +108 -0
  39. package/dist/mail/score.d.ts +67 -0
  40. package/dist/mail/score.js +174 -0
  41. package/dist/mail/sender-scores.d.ts +30 -0
  42. package/dist/mail/sender-scores.js +96 -0
  43. package/dist/mail/source.d.ts +67 -0
  44. package/dist/mail/source.js +1 -0
  45. package/dist/mail/stored.d.ts +7 -0
  46. package/dist/mail/stored.js +36 -0
  47. package/dist/mail/sync.d.ts +91 -0
  48. package/dist/mail/sync.js +572 -0
  49. package/dist/mail/testing/contract.d.ts +20 -0
  50. package/dist/mail/testing/contract.js +106 -0
  51. package/dist/mail/testing/fake.d.ts +61 -0
  52. package/dist/mail/testing/fake.js +136 -0
  53. package/dist/mail/testing/index.d.ts +2 -0
  54. package/dist/mail/testing/index.js +2 -0
  55. package/dist/mcp/agent.js +14 -2
  56. package/dist/mcp/app-server.d.ts +42 -0
  57. package/dist/mcp/app-server.js +162 -0
  58. package/dist/mcp/instructions.d.ts +12 -0
  59. package/dist/mcp/instructions.js +23 -0
  60. package/dist/mcp/server.d.ts +4 -0
  61. package/dist/mcp/server.js +2 -2
  62. package/package.json +12 -1
@@ -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[];
@@ -0,0 +1,81 @@
1
+ import { PRESET_NAMES, presetMatches } from "./presets.js";
2
+ import { registrableOf, scoreGroup } from "./score.js";
3
+ export const SUGGEST_RULE = "suggest is skip when a group has at least 20 emails, at least 80% of them list or bulk mail " +
4
+ "(List-Id, List-Unsubscribe or Precedence: bulk) and the owner never sent mail to its sender";
5
+ const share = (part, whole) => Math.round((part / whole) * 100) / 100;
6
+ const isSent = (item) => item.labels?.includes("Sent") || /(^|\/)sent\b/i.test(item.folder ?? "");
7
+ /** A list's mail is one group whoever sends it; other mail is grouped by sender. */
8
+ export const groupKey = (headers) => headers.listId === undefined ? `from:${headers.from}` : `list:${headers.listId}`;
9
+ /** Groups mail by List-Id when it has one, else by sender address; largest groups first. */
10
+ export const probeMail = (items, limit, ages = new Map()) => {
11
+ const sent = items.filter(isSent);
12
+ const recipients = new Set(sent.flatMap((item) => item.headers?.to ?? []));
13
+ const groups = new Map();
14
+ let withoutHeaders = 0;
15
+ for (const item of items) {
16
+ if (isSent(item))
17
+ continue;
18
+ const headers = item.headers;
19
+ if (headers === undefined || (headers.from === undefined && headers.listId === undefined)) {
20
+ withoutHeaders++;
21
+ continue;
22
+ }
23
+ const key = groupKey(headers);
24
+ const group = groups.get(key);
25
+ if (group)
26
+ group.push(item);
27
+ else
28
+ groups.set(key, [item]);
29
+ }
30
+ const all = [...groups.values()].map((group) => {
31
+ const senders = new Set(group.flatMap((item) => (item.headers?.from === undefined ? [] : [item.headers.from])));
32
+ const known = group.filter((item) => item.seen !== undefined);
33
+ const years = group.map((item) => new Date(item.receivedAt).getUTCFullYear());
34
+ const bulk = share(group.filter((item) => item.headers?.bulk).length, group.length);
35
+ const replied = [...senders].some((sender) => recipients.has(sender));
36
+ return {
37
+ from: [...senders][0] ?? null,
38
+ listId: group[0]?.headers?.listId ?? null,
39
+ emails: group.length,
40
+ bytes: group.reduce((sum, item) => sum + (item.size ?? 0), 0),
41
+ unread: known.length ? share(known.filter((item) => !item.seen).length, known.length) : null,
42
+ bulk,
43
+ firstYear: years.reduce((a, b) => Math.min(a, b)),
44
+ lastYear: years.reduce((a, b) => Math.max(a, b)),
45
+ replied,
46
+ suggest: group.length >= 20 && bulk >= 0.8 && !replied ? "skip" : null,
47
+ ...scoreGroup(group, { replied, bulk, ages }),
48
+ };
49
+ });
50
+ const presets = PRESET_NAMES.map((preset) => {
51
+ const hit = [...groups.values()].filter((group) => {
52
+ const replied = group.some((item) => item.headers?.from !== undefined && recipients.has(item.headers.from));
53
+ return presetMatches(preset, { ...group[0], replied });
54
+ });
55
+ return {
56
+ preset,
57
+ emails: hit.reduce((sum, group) => sum + group.length, 0),
58
+ groups: hit.length,
59
+ bytes: hit.reduce((sum, group) => sum + group.reduce((total, item) => total + (item.size ?? 0), 0), 0),
60
+ };
61
+ }).filter(({ emails }) => emails > 0);
62
+ all.sort((a, b) => b.emails - a.emails || b.bytes - a.bytes);
63
+ return {
64
+ emails: items.length,
65
+ bytes: items.reduce((sum, item) => sum + (item.size ?? 0), 0),
66
+ sent: sent.length,
67
+ withoutHeaders,
68
+ groups: all.length,
69
+ items: all.slice(0, limit),
70
+ presets,
71
+ };
72
+ };
73
+ /** The sender domains of the groups worth asking a registry about: not a big mailbox provider, never replied to. */
74
+ export const domainsToAge = (groups, skip) => [
75
+ ...new Set(groups.flatMap((group) => {
76
+ if (group.replied || group.from === null)
77
+ return [];
78
+ const domain = registrableOf(group.from);
79
+ return skip.has(domain) ? [] : [domain];
80
+ })),
81
+ ];
@@ -0,0 +1,61 @@
1
+ import type { MailRule } from "./rules.js";
2
+ /** Inclusive UID stretches, sorted and apart: a mailbox read newest first in batches stays a few stretches. */
3
+ export type Ranges = [number, number][];
4
+ /** Where a folder's listing stopped, kept in the store beside the mail it describes, so a restored backup agrees. */
5
+ export interface ResumePoint {
6
+ validity: string;
7
+ done: Ranges;
8
+ /** The rules the done mail was decided under; other rules decide it again. */
9
+ rules: string;
10
+ /**
11
+ * The widest recent listing of every message, the only kind that proves deletions and refreshes labels and
12
+ * flags: a later run whose window it covers may list only what is not done.
13
+ */
14
+ full: {
15
+ since: string;
16
+ at: string;
17
+ };
18
+ /** The widest window searched: its messages, and how many of them are done; a narrower run leaves it. */
19
+ window: {
20
+ since: string;
21
+ total: number;
22
+ done: number;
23
+ };
24
+ /**
25
+ * The widest window ever listed, kept across a rule change, a new UIDVALIDITY or --rescan, which start `window`
26
+ * over: a timer's 30 days must never stand in for a mailbox read since 2004.
27
+ */
28
+ widest?: {
29
+ since: string;
30
+ total: number;
31
+ at: string;
32
+ };
33
+ /** What the latest run listed, whatever its window. */
34
+ last?: {
35
+ since: string;
36
+ total: number;
37
+ done: number;
38
+ at: string;
39
+ };
40
+ updatedAt: string;
41
+ }
42
+ /** A whole listing at least this often, so deletions, labels and read state are never more than a day old. */
43
+ export declare const FULL_PASS_MS = 86400000;
44
+ export declare const resumeKey: (folder: string) => string;
45
+ /** An mbox file has no UIDs: an unchanged file read to the end has nothing new. */
46
+ export declare const MBOX_RESUME = "mail_resume:mbox";
47
+ export interface FileStamp {
48
+ size: number;
49
+ mtimeMs: number;
50
+ }
51
+ export interface MboxPoint extends FileStamp {
52
+ /** Every email of the file was read: a run cut short by --limit reads it again. */
53
+ complete: boolean;
54
+ updatedAt: string;
55
+ }
56
+ export declare const rangesOf: (values: Iterable<number>) => Ranges;
57
+ export declare const mergeRanges: (a: Ranges, b: Ranges) => Ranges;
58
+ export declare const within: (ranges: Ranges, value: number) => boolean;
59
+ /** Only what decides mail: a changed reason or date is the same rule. */
60
+ export declare const rulesKey: (rules: readonly MailRule[]) => string;
61
+ export declare const parsePoint: <T>(value: string | undefined) => T | undefined;