@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.
- package/CHANGELOG.md +12 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/mail/bulk.d.ts +7 -0
- package/dist/mail/bulk.js +3 -0
- package/dist/mail/facts.d.ts +9 -0
- package/dist/mail/facts.js +22 -0
- package/dist/mail/folders.d.ts +10 -0
- package/dist/mail/folders.js +13 -0
- package/dist/mail/gone.d.ts +20 -0
- package/dist/mail/gone.js +53 -0
- package/dist/mail/headers.d.ts +43 -0
- package/dist/mail/headers.js +88 -0
- package/dist/mail/index.d.ts +17 -0
- package/dist/mail/index.js +16 -0
- package/dist/mail/parse.d.ts +24 -0
- package/dist/mail/parse.js +79 -0
- package/dist/mail/presets.d.ts +30 -0
- package/dist/mail/presets.js +95 -0
- package/dist/mail/probe.d.ts +49 -0
- package/dist/mail/probe.js +81 -0
- package/dist/mail/resume.d.ts +61 -0
- package/dist/mail/resume.js +61 -0
- package/dist/mail/rule-input.d.ts +14 -0
- package/dist/mail/rule-input.js +32 -0
- package/dist/mail/rule-tags.d.ts +40 -0
- package/dist/mail/rule-tags.js +87 -0
- package/dist/mail/rules.d.ts +83 -0
- package/dist/mail/rules.js +108 -0
- package/dist/mail/score.d.ts +67 -0
- package/dist/mail/score.js +174 -0
- package/dist/mail/sender-scores.d.ts +30 -0
- package/dist/mail/sender-scores.js +96 -0
- package/dist/mail/source.d.ts +67 -0
- package/dist/mail/source.js +1 -0
- package/dist/mail/stored.d.ts +7 -0
- package/dist/mail/stored.js +36 -0
- package/dist/mail/sync.d.ts +91 -0
- package/dist/mail/sync.js +572 -0
- package/dist/mail/testing/contract.d.ts +20 -0
- package/dist/mail/testing/contract.js +106 -0
- package/dist/mail/testing/fake.d.ts +61 -0
- package/dist/mail/testing/fake.js +136 -0
- package/dist/mail/testing/index.d.ts +2 -0
- package/dist/mail/testing/index.js +2 -0
- package/package.json +12 -1
|
@@ -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;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** A whole listing at least this often, so deletions, labels and read state are never more than a day old. */
|
|
3
|
+
export const FULL_PASS_MS = 86_400_000;
|
|
4
|
+
export const resumeKey = (folder) => `mail_resume:${folder}`;
|
|
5
|
+
/** An mbox file has no UIDs: an unchanged file read to the end has nothing new. */
|
|
6
|
+
export const MBOX_RESUME = "mail_resume:mbox";
|
|
7
|
+
export const rangesOf = (values) => {
|
|
8
|
+
const sorted = [...new Set(values)].sort((a, b) => a - b);
|
|
9
|
+
const ranges = [];
|
|
10
|
+
for (const value of sorted) {
|
|
11
|
+
const last = ranges.at(-1);
|
|
12
|
+
if (last !== undefined && value === last[1] + 1)
|
|
13
|
+
last[1] = value;
|
|
14
|
+
else
|
|
15
|
+
ranges.push([value, value]);
|
|
16
|
+
}
|
|
17
|
+
return ranges;
|
|
18
|
+
};
|
|
19
|
+
export const mergeRanges = (a, b) => {
|
|
20
|
+
const merged = [];
|
|
21
|
+
for (const [from, to] of [...a, ...b].sort(([x], [y]) => x - y)) {
|
|
22
|
+
const last = merged.at(-1);
|
|
23
|
+
if (last !== undefined && from <= last[1] + 1)
|
|
24
|
+
last[1] = Math.max(last[1], to);
|
|
25
|
+
else
|
|
26
|
+
merged.push([from, to]);
|
|
27
|
+
}
|
|
28
|
+
return merged;
|
|
29
|
+
};
|
|
30
|
+
export const within = (ranges, value) => {
|
|
31
|
+
let low = 0;
|
|
32
|
+
let high = ranges.length - 1;
|
|
33
|
+
while (low <= high) {
|
|
34
|
+
const middle = (low + high) >> 1;
|
|
35
|
+
const [from, to] = ranges[middle];
|
|
36
|
+
if (value < from)
|
|
37
|
+
high = middle - 1;
|
|
38
|
+
else if (value > to)
|
|
39
|
+
low = middle + 1;
|
|
40
|
+
else
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
};
|
|
45
|
+
/** Only what decides mail: a changed reason or date is the same rule. */
|
|
46
|
+
export const rulesKey = (rules) => createHash("sha256")
|
|
47
|
+
.update(JSON.stringify(rules
|
|
48
|
+
.map(({ match, action }) => JSON.stringify([action, match.from ?? null, match.domain ?? null, match.list_id ?? null]))
|
|
49
|
+
.sort()))
|
|
50
|
+
.digest("hex")
|
|
51
|
+
.slice(0, 16);
|
|
52
|
+
export const parsePoint = (value) => {
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(value);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MailAction, MailRule } from "./rules.js";
|
|
2
|
+
export declare const STORE_ACTION: Record<MailAction, string>;
|
|
3
|
+
export declare const ruleInput: (rule: MailRule, source?: string) => {
|
|
4
|
+
conditions: Record<string, string | number | boolean>;
|
|
5
|
+
actions: {
|
|
6
|
+
store: string;
|
|
7
|
+
tags?: string[] | undefined;
|
|
8
|
+
tags_skipped?: Record<string, string> | undefined;
|
|
9
|
+
};
|
|
10
|
+
protected: boolean;
|
|
11
|
+
source: string;
|
|
12
|
+
reason: string | null;
|
|
13
|
+
decidedAt: number;
|
|
14
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { momentOf } from "../services/moment.js";
|
|
2
|
+
import { conditionKeys } from "./rules.js";
|
|
3
|
+
export const STORE_ACTION = { keep: "full", envelope: "envelope", skip: "skip" };
|
|
4
|
+
const LOWER = ["from", "domain", "list_id", "subject"];
|
|
5
|
+
const conditionsOf = (match) => Object.fromEntries(conditionKeys(match).map((key) => {
|
|
6
|
+
const value = match[key];
|
|
7
|
+
return [
|
|
8
|
+
key,
|
|
9
|
+
LOWER.includes(key) && typeof value === "string" ? value.toLowerCase() : value,
|
|
10
|
+
];
|
|
11
|
+
}));
|
|
12
|
+
/** When the owner decided: a time without a zone is read in the computer's; an unreadable one is when it is saved. */
|
|
13
|
+
const decidedAtOf = (value) => {
|
|
14
|
+
try {
|
|
15
|
+
return momentOf(value, "decided_at");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return Date.now();
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
export const ruleInput = (rule, source = rule.source ?? "owner") => ({
|
|
22
|
+
conditions: conditionsOf(rule.match),
|
|
23
|
+
actions: {
|
|
24
|
+
store: STORE_ACTION[rule.action],
|
|
25
|
+
...(rule.tags?.length ? { tags: rule.tags } : {}),
|
|
26
|
+
...(rule.tags_skipped && Object.keys(rule.tags_skipped).length ? { tags_skipped: rule.tags_skipped } : {}),
|
|
27
|
+
},
|
|
28
|
+
protected: rule.protected === true,
|
|
29
|
+
source,
|
|
30
|
+
reason: rule.reason ?? null,
|
|
31
|
+
decidedAt: decidedAtOf(rule.decided_at),
|
|
32
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AccountKey, MessageStore } from "../store/index.js";
|
|
2
|
+
import type { MailRule } from "./rules.js";
|
|
3
|
+
type MessageRef = {
|
|
4
|
+
type: "message";
|
|
5
|
+
chatId: string;
|
|
6
|
+
messageId: string;
|
|
7
|
+
};
|
|
8
|
+
export interface TagOutcome {
|
|
9
|
+
/** A rule's tag the store refused as too close to an existing label, put on mail as that label instead. */
|
|
10
|
+
reused: {
|
|
11
|
+
tag: string;
|
|
12
|
+
as: string;
|
|
13
|
+
}[];
|
|
14
|
+
/** A rule's tag the store refused with nothing to reuse: the mail is stored, untagged. */
|
|
15
|
+
skipped: {
|
|
16
|
+
tag: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
emails: number;
|
|
19
|
+
}[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Tags mail for confirmed rules. A label the store refuses never fails the import: the label it points to is
|
|
23
|
+
* reused, and a tag with nothing to reuse is skipped and counted.
|
|
24
|
+
*/
|
|
25
|
+
export declare const ruleTagger: (store: MessageStore, key: AccountKey) => {
|
|
26
|
+
tag: (ref: MessageRef, tags: readonly string[]) => Promise<void>;
|
|
27
|
+
outcome: () => TagOutcome;
|
|
28
|
+
};
|
|
29
|
+
/** Writes what a run learned back into the rules, so the next run tags with the reused label and skips the rest. */
|
|
30
|
+
export declare const settleRuleTags: (store: MessageStore, accountId: number, rules: readonly MailRule[], outcome: TagOutcome) => Promise<void>;
|
|
31
|
+
/** Proposed tags matched to labels that already exist, before any is created: `reuse` exact, `similar` closest. */
|
|
32
|
+
export declare const mapProposedTags: (store: MessageStore, rules: readonly MailRule[]) => Promise<{
|
|
33
|
+
rules: MailRule[];
|
|
34
|
+
mapping: {
|
|
35
|
+
tag: string;
|
|
36
|
+
as: string;
|
|
37
|
+
why: "reuse" | "similar";
|
|
38
|
+
}[];
|
|
39
|
+
}>;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ruleInput } from "./rule-input.js";
|
|
2
|
+
const reuseOf = (error) => {
|
|
3
|
+
const { details } = error;
|
|
4
|
+
return details?.reason === "tag-review.required" ? details.candidates?.[0]?.name : undefined;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Tags mail for confirmed rules. A label the store refuses never fails the import: the label it points to is
|
|
8
|
+
* reused, and a tag with nothing to reuse is skipped and counted.
|
|
9
|
+
*/
|
|
10
|
+
export const ruleTagger = (store, key) => {
|
|
11
|
+
const resolved = new Map();
|
|
12
|
+
const reasons = new Map();
|
|
13
|
+
const skippedEmails = new Map();
|
|
14
|
+
const add = async (ref, tag) => {
|
|
15
|
+
const known = resolved.get(tag);
|
|
16
|
+
if (known === null) {
|
|
17
|
+
skippedEmails.set(tag, (skippedEmails.get(tag) ?? 0) + 1);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const name = known ?? tag;
|
|
21
|
+
try {
|
|
22
|
+
await store.tags.addTags(key, ref, [name]);
|
|
23
|
+
if (known === undefined)
|
|
24
|
+
resolved.set(tag, name);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const reuse = known === undefined ? reuseOf(error) : undefined;
|
|
28
|
+
if (reuse !== undefined) {
|
|
29
|
+
resolved.set(tag, reuse);
|
|
30
|
+
return add(ref, tag);
|
|
31
|
+
}
|
|
32
|
+
resolved.set(tag, null);
|
|
33
|
+
reasons.set(tag, error.message ?? String(error));
|
|
34
|
+
skippedEmails.set(tag, (skippedEmails.get(tag) ?? 0) + 1);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
tag: async (ref, tags) => {
|
|
39
|
+
for (const tag of tags)
|
|
40
|
+
await add(ref, tag);
|
|
41
|
+
},
|
|
42
|
+
outcome: () => ({
|
|
43
|
+
reused: [...resolved].flatMap(([tag, as]) => (as !== null && as !== tag ? [{ tag, as }] : [])),
|
|
44
|
+
skipped: [...reasons].map(([tag, reason]) => ({ tag, reason, emails: skippedEmails.get(tag) ?? 0 })),
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/** Writes what a run learned back into the rules, so the next run tags with the reused label and skips the rest. */
|
|
49
|
+
export const settleRuleTags = async (store, accountId, rules, outcome) => {
|
|
50
|
+
if (outcome.reused.length === 0 && outcome.skipped.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
const as = new Map(outcome.reused.map(({ tag, as }) => [tag, as]));
|
|
53
|
+
const skipped = new Map(outcome.skipped.map(({ tag, reason }) => [tag, reason]));
|
|
54
|
+
for (const rule of rules) {
|
|
55
|
+
if (rule.id === undefined || !rule.tags?.some((tag) => as.has(tag) || skipped.has(tag)))
|
|
56
|
+
continue;
|
|
57
|
+
const tags = [...new Set(rule.tags.filter((tag) => !skipped.has(tag)).map((tag) => as.get(tag) ?? tag))];
|
|
58
|
+
const tagsSkipped = { ...rule.tags_skipped };
|
|
59
|
+
for (const tag of rule.tags) {
|
|
60
|
+
const why = skipped.get(tag);
|
|
61
|
+
if (why !== undefined)
|
|
62
|
+
tagsSkipped[tag] = why;
|
|
63
|
+
}
|
|
64
|
+
await store.mail.rules.upsert(accountId, ruleInput({ ...rule, tags, tags_skipped: tagsSkipped }), Date.now());
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/** Proposed tags matched to labels that already exist, before any is created: `reuse` exact, `similar` closest. */
|
|
68
|
+
export const mapProposedTags = async (store, rules) => {
|
|
69
|
+
const mapping = [];
|
|
70
|
+
for (const tag of new Set(rules.flatMap(({ tags }) => tags ?? []))) {
|
|
71
|
+
const plan = await store.knowledge.tagPreflight(tag).catch(() => undefined);
|
|
72
|
+
const as = plan?.disposition === "reuse"
|
|
73
|
+
? plan.existing?.name
|
|
74
|
+
: plan?.disposition === "review"
|
|
75
|
+
? plan.suggestions[0]?.entry.name
|
|
76
|
+
: undefined;
|
|
77
|
+
if (as !== undefined && as !== tag)
|
|
78
|
+
mapping.push({ tag, as, why: plan?.disposition === "reuse" ? "reuse" : "similar" });
|
|
79
|
+
}
|
|
80
|
+
if (mapping.length === 0)
|
|
81
|
+
return { rules: [...rules], mapping };
|
|
82
|
+
const as = new Map(mapping.map(({ tag, as }) => [tag, as]));
|
|
83
|
+
return {
|
|
84
|
+
rules: rules.map((rule) => rule.tags?.length ? { ...rule, tags: [...new Set(rule.tags.map((tag) => as.get(tag) ?? tag))] } : rule),
|
|
85
|
+
mapping,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { MailHeaders } from "./headers.js";
|
|
2
|
+
import { type PresetName } from "./presets.js";
|
|
3
|
+
/** All given conditions must hold; a rule with none matches nothing. */
|
|
4
|
+
export interface MailRuleMatch {
|
|
5
|
+
from?: string;
|
|
6
|
+
/** A word the sender's address or display name contains, any case: Gmail's `from:smith`. */
|
|
7
|
+
from_contains?: string;
|
|
8
|
+
domain?: string;
|
|
9
|
+
/** One recipient address in To or Cc. */
|
|
10
|
+
to?: string;
|
|
11
|
+
list_id?: string;
|
|
12
|
+
/** Words that must all appear in the subject, any case. */
|
|
13
|
+
subject?: string;
|
|
14
|
+
older_than_days?: number;
|
|
15
|
+
larger_than_bytes?: number;
|
|
16
|
+
/** Sent as `multipart/mixed`, the way mail with attachments is. */
|
|
17
|
+
has_attachments?: boolean;
|
|
18
|
+
/** A Gmail label, by its plain name: `Promotions`, `Inbox`, or one the owner made. */
|
|
19
|
+
label?: string;
|
|
20
|
+
unread?: boolean;
|
|
21
|
+
/** List-Id, List-Unsubscribe or `Precedence: bulk`. */
|
|
22
|
+
bulk?: boolean;
|
|
23
|
+
/** The sender's score (memo mail inspect) is at least this. */
|
|
24
|
+
score_at_least?: number;
|
|
25
|
+
/** A built-in kind of mail, see presets.ts. */
|
|
26
|
+
preset?: PresetName;
|
|
27
|
+
}
|
|
28
|
+
export type MailAction = "skip" | "envelope" | "keep";
|
|
29
|
+
export interface MailRule {
|
|
30
|
+
/** The store's id; absent until the rule is saved. */
|
|
31
|
+
id?: number;
|
|
32
|
+
match: MailRuleMatch;
|
|
33
|
+
/** skip: never read or stored · envelope: stored without its text, so it can be counted and cleaned later. */
|
|
34
|
+
action: MailAction;
|
|
35
|
+
/** memo tags put on every email the rule matches, with any action. */
|
|
36
|
+
tags?: string[];
|
|
37
|
+
/** Tags the store refused with no label to reuse, and why; the mail is stored untagged. */
|
|
38
|
+
tags_skipped?: Record<string, string>;
|
|
39
|
+
reason?: string;
|
|
40
|
+
/** Stored in full, and no cleanup may cut, archive or trash it, whatever other rules or presets say. */
|
|
41
|
+
protected?: boolean;
|
|
42
|
+
/** owner, preset:<name>, probe or gmail-filter. */
|
|
43
|
+
source?: string;
|
|
44
|
+
decided_at: string;
|
|
45
|
+
}
|
|
46
|
+
/** What is known of one email when a rule decides it: its headers at list time, and what the listing says. */
|
|
47
|
+
export interface MailFacts {
|
|
48
|
+
headers?: MailHeaders;
|
|
49
|
+
receivedAt?: string;
|
|
50
|
+
size?: number;
|
|
51
|
+
labels?: string[];
|
|
52
|
+
seen?: boolean;
|
|
53
|
+
/** The sender group's score, when a rule asks for one. */
|
|
54
|
+
score?: number;
|
|
55
|
+
/** The owner wrote to this sender. */
|
|
56
|
+
replied?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** Unknown facts never match: a rule about size skips an email whose size the listing did not give. */
|
|
59
|
+
export declare const matches: (match: MailRuleMatch, facts: MailFacts, now?: number) => boolean;
|
|
60
|
+
/**
|
|
61
|
+
* The rule that decides an email. A protected rule wins over everything and keeps the mail whole; then keep wins
|
|
62
|
+
* over envelope, envelope over skip, so a keep for one sender overrides a skip of its domain and mail both record
|
|
63
|
+
* and drop rules match is recorded, since a record can be cleaned later. An explicit rule wins over a preset.
|
|
64
|
+
*/
|
|
65
|
+
export declare const decide: (rules: readonly MailRule[], facts: MailFacts | undefined) => MailRule | undefined;
|
|
66
|
+
/** The tags of every rule that matches, whichever rule decides the action. */
|
|
67
|
+
export declare const tagsOf: (rules: readonly MailRule[], facts: MailFacts) => string[];
|
|
68
|
+
export declare const conditionKeys: (match: MailRuleMatch) => (keyof MailRuleMatch)[];
|
|
69
|
+
/** A short, stable name of a match: `domain shop.example`, or `subject invoice, larger_than_bytes 1000`. */
|
|
70
|
+
export declare const describeMatch: (match: MailRuleMatch) => string;
|
|
71
|
+
/** Where the store keeps the emails one envelope rule recorded: a mailbox of its own, per rule. */
|
|
72
|
+
export declare const envelopeBox: (match: MailRuleMatch) => {
|
|
73
|
+
externalId: string;
|
|
74
|
+
name: string;
|
|
75
|
+
kind: string;
|
|
76
|
+
};
|
|
77
|
+
export declare const ENVELOPE = "envelope";
|
|
78
|
+
export declare const sameMatch: (a: MailRuleMatch, b: MailRuleMatch) => boolean;
|
|
79
|
+
export declare const envelopeBoxOf: (externalId: string) => {
|
|
80
|
+
externalId: string;
|
|
81
|
+
name: string;
|
|
82
|
+
kind: string;
|
|
83
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { presetMatches } from "./presets.js";
|
|
2
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
const domainOf = (address) => address.slice(address.lastIndexOf("@") + 1);
|
|
4
|
+
/** A domain also matches its subdomains: bulk mail often comes from `mail.` or `news.` of the same domain. */
|
|
5
|
+
const domainMatches = (wanted, sender) => {
|
|
6
|
+
if (sender === undefined)
|
|
7
|
+
return false;
|
|
8
|
+
const host = domainOf(sender);
|
|
9
|
+
const domain = wanted.toLowerCase();
|
|
10
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
11
|
+
};
|
|
12
|
+
const words = (text) => text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
13
|
+
/** Unknown facts never match: a rule about size skips an email whose size the listing did not give. */
|
|
14
|
+
export const matches = (match, facts, now = Date.now()) => {
|
|
15
|
+
const conditions = Object.entries(match).filter(([, value]) => value !== undefined);
|
|
16
|
+
if (conditions.length === 0)
|
|
17
|
+
return false;
|
|
18
|
+
const { headers } = facts;
|
|
19
|
+
return conditions.every(([key, value]) => {
|
|
20
|
+
switch (key) {
|
|
21
|
+
case "from":
|
|
22
|
+
return headers?.from === String(value).toLowerCase();
|
|
23
|
+
case "from_contains": {
|
|
24
|
+
const word = String(value).toLowerCase();
|
|
25
|
+
return [headers?.from, headers?.fromName?.toLowerCase()].some((part) => part?.includes(word) ?? false);
|
|
26
|
+
}
|
|
27
|
+
case "domain":
|
|
28
|
+
return domainMatches(String(value), headers?.from);
|
|
29
|
+
case "to":
|
|
30
|
+
return headers?.to.includes(String(value).toLowerCase()) ?? false;
|
|
31
|
+
case "list_id":
|
|
32
|
+
return headers?.listId === String(value).toLowerCase();
|
|
33
|
+
case "subject": {
|
|
34
|
+
const subject = headers?.subject?.toLowerCase();
|
|
35
|
+
return subject !== undefined && words(String(value)).every((word) => subject.includes(word));
|
|
36
|
+
}
|
|
37
|
+
case "older_than_days":
|
|
38
|
+
return facts.receivedAt !== undefined && now - Date.parse(facts.receivedAt) > Number(value) * DAY_MS;
|
|
39
|
+
case "larger_than_bytes":
|
|
40
|
+
return facts.size !== undefined && facts.size > Number(value);
|
|
41
|
+
case "has_attachments":
|
|
42
|
+
return headers !== undefined && (headers.attachments === true) === value;
|
|
43
|
+
case "label":
|
|
44
|
+
return facts.labels?.some((label) => label.toLowerCase() === String(value).toLowerCase()) ?? false;
|
|
45
|
+
case "unread":
|
|
46
|
+
return facts.seen !== undefined && !facts.seen === value;
|
|
47
|
+
case "bulk":
|
|
48
|
+
return headers !== undefined && headers.bulk === value;
|
|
49
|
+
case "score_at_least":
|
|
50
|
+
return facts.score !== undefined && facts.score >= Number(value);
|
|
51
|
+
case "preset":
|
|
52
|
+
return presetMatches(value, facts);
|
|
53
|
+
default:
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
const RANK = { keep: 0, envelope: 1, skip: 2 };
|
|
59
|
+
const strength = (rule) => (rule.protected ? 0 : 10) + RANK[rule.action] + (rule.source?.startsWith("preset:") ? 5 : 0);
|
|
60
|
+
/**
|
|
61
|
+
* The rule that decides an email. A protected rule wins over everything and keeps the mail whole; then keep wins
|
|
62
|
+
* over envelope, envelope over skip, so a keep for one sender overrides a skip of its domain and mail both record
|
|
63
|
+
* and drop rules match is recorded, since a record can be cleaned later. An explicit rule wins over a preset.
|
|
64
|
+
*/
|
|
65
|
+
export const decide = (rules, facts) => {
|
|
66
|
+
if (facts?.headers === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const hit = rules.filter((rule) => matches(rule.match, facts, now)).sort((a, b) => strength(a) - strength(b))[0];
|
|
70
|
+
return hit?.protected ? { ...hit, action: "keep" } : hit;
|
|
71
|
+
};
|
|
72
|
+
/** The tags of every rule that matches, whichever rule decides the action. */
|
|
73
|
+
export const tagsOf = (rules, facts) => {
|
|
74
|
+
if (facts.headers === undefined)
|
|
75
|
+
return [];
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
return [...new Set(rules.flatMap((rule) => (rule.tags?.length && matches(rule.match, facts, now) ? rule.tags : [])))];
|
|
78
|
+
};
|
|
79
|
+
/** The only conditions an envelope mailbox can be named by: one sender, domain or list. */
|
|
80
|
+
const SINGLE = ["from", "domain", "list_id"];
|
|
81
|
+
export const conditionKeys = (match) => Object.keys(match).filter((key) => match[key] !== undefined).sort();
|
|
82
|
+
/** A short, stable name of a match: `domain shop.example`, or `subject invoice, larger_than_bytes 1000`. */
|
|
83
|
+
export const describeMatch = (match) => conditionKeys(match)
|
|
84
|
+
.map((key) => `${key === "list_id" ? "list-id" : key.replace(/_/g, "-")} ${String(match[key])}`)
|
|
85
|
+
.join(", ");
|
|
86
|
+
/** Where the store keeps the emails one envelope rule recorded: a mailbox of its own, per rule. */
|
|
87
|
+
export const envelopeBox = (match) => {
|
|
88
|
+
const keys = conditionKeys(match);
|
|
89
|
+
const single = keys.length === 1 && SINGLE.includes(keys[0]);
|
|
90
|
+
const [kind, value] = single
|
|
91
|
+
? [keys[0] === "list_id" ? "list-id" : String(keys[0]), String(match[keys[0]])]
|
|
92
|
+
: ["rule", describeMatch(match)];
|
|
93
|
+
return {
|
|
94
|
+
externalId: `memo:envelope:${kind}:${value.toLowerCase()}`,
|
|
95
|
+
name: `envelope: ${kind} ${value}`,
|
|
96
|
+
kind: ENVELOPE,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
export const ENVELOPE = "envelope";
|
|
100
|
+
const normalized = (match) => JSON.stringify(conditionKeys(match).map((key) => {
|
|
101
|
+
const value = match[key];
|
|
102
|
+
return [key, typeof value === "string" ? value.toLowerCase() : value];
|
|
103
|
+
}));
|
|
104
|
+
export const sameMatch = (a, b) => normalized(a) === normalized(b);
|
|
105
|
+
export const envelopeBoxOf = (externalId) => {
|
|
106
|
+
const [, , kind, ...value] = externalId.split(":");
|
|
107
|
+
return { externalId, name: `envelope: ${kind} ${value.join(":")}`, kind: ENVELOPE };
|
|
108
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Listed } from "./source.js";
|
|
2
|
+
/**
|
|
3
|
+
* How much each sign of spam, a scam or a robot adds to a sender's score. Gmail filters spam before it reaches
|
|
4
|
+
* All Mail, so these mostly find what got through: spoofed senders, scams and robots no rule covers yet.
|
|
5
|
+
*/
|
|
6
|
+
export declare const WEIGHTS: {
|
|
7
|
+
readonly spam_folder: 4;
|
|
8
|
+
readonly dmarc_fail: 3;
|
|
9
|
+
readonly brand_spoof: 3;
|
|
10
|
+
readonly young_domain: 5;
|
|
11
|
+
readonly reply_to_elsewhere: 1;
|
|
12
|
+
readonly never_opened: 2;
|
|
13
|
+
readonly dkim_not_passed: 2;
|
|
14
|
+
readonly disposable_domain: 2;
|
|
15
|
+
readonly bulk_never_replied: 1;
|
|
16
|
+
readonly replied: -5;
|
|
17
|
+
};
|
|
18
|
+
export type Signal = keyof typeof WEIGHTS;
|
|
19
|
+
/**
|
|
20
|
+
* The signs of a forged or fraudulent sender, as opposed to how the owner treats ordinary bulk mail. A failed DMARC
|
|
21
|
+
* check is one only beside another: alone it marks real senders with a broken setup or a forwarding path.
|
|
22
|
+
*/
|
|
23
|
+
export declare const FRAUD_SIGNALS: readonly Signal[];
|
|
24
|
+
export declare const SCORE_RULE: string;
|
|
25
|
+
/**
|
|
26
|
+
* DMARC failures before this date are not counted: senders then often published a policy their own mail did not yet
|
|
27
|
+
* meet.
|
|
28
|
+
*/
|
|
29
|
+
export declare const DMARC_SINCE = "2022-01-01T00:00:00.000Z";
|
|
30
|
+
/** A domain registered too recently to trust: under this many days old when the mail arrived. */
|
|
31
|
+
export declare const YOUNG_DAYS = 180;
|
|
32
|
+
/** What the registry said about a domain; `created` is ISO 8601, `unsupported` when its registry has no RDAP. */
|
|
33
|
+
export interface DomainAge {
|
|
34
|
+
created?: string;
|
|
35
|
+
unsupported?: boolean;
|
|
36
|
+
checkedAt: string;
|
|
37
|
+
}
|
|
38
|
+
/** The part of a host a person registers: `mail.news.example.co.uk` is `example.co.uk`. */
|
|
39
|
+
export declare const registrableOf: (address: string) => string;
|
|
40
|
+
/**
|
|
41
|
+
* The big mailbox providers. Their domains are years old and say nothing about the sender, so they are never
|
|
42
|
+
* looked up; a brand display name sent from one of them is the classic scam.
|
|
43
|
+
*/
|
|
44
|
+
export declare const FREE_MAIL: Set<string>;
|
|
45
|
+
/**
|
|
46
|
+
* The brand a display name claims while the address belongs to someone else, if any. "Google via LinkedIn" from
|
|
47
|
+
* LinkedIn's own domain is LinkedIn passing a page on; "PayPal via Desk" from anywhere else is still judged whole.
|
|
48
|
+
*/
|
|
49
|
+
export declare const spoofedBrand: (name: string | undefined, address: string | undefined) => string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Under YOUNG_DAYS old at `at`; false when the age is not known, and when the registration came after `at`: the
|
|
52
|
+
* domain lapsed and someone registered it again later, which says nothing about the mail that came before.
|
|
53
|
+
*/
|
|
54
|
+
export declare const youngAt: (age: DomainAge | undefined, at: string) => boolean;
|
|
55
|
+
export interface Scored {
|
|
56
|
+
score: number;
|
|
57
|
+
signals: Signal[];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A sender group's score from the facts its emails carry. A sign counts when it holds for at least half the group's
|
|
61
|
+
* emails, so one odd email does not mark a sender; a spoofed brand counts on any one.
|
|
62
|
+
*/
|
|
63
|
+
export declare const scoreGroup: (items: Listed[], context: {
|
|
64
|
+
replied: boolean;
|
|
65
|
+
bulk: number;
|
|
66
|
+
ages: ReadonlyMap<string, DomainAge>;
|
|
67
|
+
}) => Scored;
|