spamscanner 7.0.3 → 7.0.5

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.
@@ -27,21 +27,31 @@ export function loadSea(load?: Function): object | null;
27
27
  * The bundled model as a classifier: from the package's model file, or from
28
28
  * the standalone binary.
29
29
  * @param {object} [options] - classifier options
30
- * @param {object} [where] - for tests: file (the model path, null for none) and sea (the node:sea module)
30
+ * @param {{file?: string|null, sea?: {isSea: () => boolean, getAsset: (key: string, encoding: string) => string}|null}} [where] - for tests: file (the model path, null for none) and sea (the node:sea module)
31
31
  * @returns {Classifier} an empty classifier when no model is found
32
32
  */
33
- export function loadDefaultModel(options?: object, { file, sea }?: object): Classifier;
33
+ export function loadDefaultModel(options?: object, { file, sea }?: {
34
+ file?: string | null;
35
+ sea?: {
36
+ isSea: () => boolean;
37
+ getAsset: (key: string, encoding: string) => string;
38
+ } | null;
39
+ }): Classifier;
34
40
  /**
35
- * Load a model file written by saveModel or `spamscanner train`. Files are
36
- * read once per process; every classifier made from the same file shares its
37
- * counts, and learning goes into the classifier's own copy.
41
+ * Load a model file written by saveModel or `spamscanner train`. A file is
42
+ * read once per process, and again when it changes (its modification time
43
+ * or size); every classifier made from the same file shares its counts, and
44
+ * learning goes into the classifier's own copy.
38
45
  * @param {string} file
39
46
  * @param {object} [options] - classifier options that override the model's
40
47
  * @returns {Classifier}
41
48
  */
42
49
  export function loadModel(file: string, options?: object): Classifier;
43
50
  /**
44
- * Write a classifier to a file, creating its directory.
51
+ * Write a classifier to a file, creating its directory. The model is written
52
+ * to a new temporary file next to it first, flushed to disk, then renamed, so
53
+ * a process loading the file, or one reading it after a crash, never reads
54
+ * half of it. A file replaced keeps its permissions.
45
55
  * @param {Classifier} classifier
46
56
  * @param {string} file
47
57
  * @param {object} [options] - see Classifier#toJSON
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The message as mailauth is to read it: its address fields bounded as
3
+ * parseMessage bounds them (mailauth reads From and Return-Path with the same
4
+ * parser), and, when the header would take mailauth longer than linear time
5
+ * (it joins folded lines, and finds the fields a DKIM signature names, in
6
+ * time that grows with the square of their number), the header cut down:
7
+ * its folded lines unfolded (which relaxed canonicalization of DKIM does
8
+ * too) and only the fields that say who sent the message and how, as many
9
+ * as smallerHeader keeps, each up to MAX_FIELD characters (address fields as
10
+ * bounded), and at most MAX_SIGNATURES signatures. Only the first
11
+ * MAX_DKIM_SIGNATURES DKIM-Signature fields are ever kept (with their folded
12
+ * lines; the body is untouched). `fromCut` is whether a
13
+ * From field was cut or left out: then mailauth did not read the From
14
+ * address the message gives.
15
+ * @param {Buffer} raw
16
+ * @returns {{raw: Buffer, fromCut: boolean}}
17
+ */
18
+ export function boundedForAuth(raw: Buffer): {
19
+ raw: Buffer;
20
+ fromCut: boolean;
21
+ };
22
+ /**
23
+ * The simpleParser of mailparser on a message it can finish (see finishable),
24
+ * settled within `timeout`: a parse that has not settled by then is stopped
25
+ * (its parser destroyed) and rejected with an ETIMEDOUT error.
26
+ * @param {Buffer|string} raw
27
+ * @param {object} [options]
28
+ * @param {number} [options.timeout] - milliseconds (PARSE_TIMEOUT)
29
+ * @param {typeof simpleParser} [options.parser] - for tests
30
+ * @returns {Promise<object>} the parsed message
31
+ */
32
+ export function simpleParse(raw: Buffer | string, { timeout, parser }?: {
33
+ timeout?: number;
34
+ parser?: typeof simpleParser;
35
+ }): Promise<object>;
36
+ /**
37
+ * Parse a message with mailparser, which does not make text of HTML here:
38
+ * `text` is that of the plain text parts only (empty for a message of HTML
39
+ * alone), and the text HTML shows is read by getFeatures.
40
+ * @param {Buffer|string} raw
41
+ * @param {{timeout?: number}} [options] - milliseconds for the parse (PARSE_TIMEOUT)
42
+ * @returns {Promise<object>} the parsed message
43
+ */
44
+ export function parseMessage(raw: Buffer | string, options?: {
45
+ timeout?: number;
46
+ }): Promise<object>;
47
+ /**
48
+ * A message mailparser can finish: it never finishes one that ends in the
49
+ * header of an attached message (however that header names or folds its
50
+ * type, or only a file name like "fw.eml") without the blank line after it,
51
+ * so every message gets one.
52
+ * @param {Buffer|string} raw
53
+ * @returns {Buffer}
54
+ */
55
+ export function finishable(raw: Buffer | string): Buffer;
56
+ /**
57
+ * The message with each header field folded on more than MAX_FOLDS lines
58
+ * unfolded: its line breaks and the white space after each made one space,
59
+ * as mailparser reads a field (libmime's decodeHeader), so what it reads is
60
+ * the same. Header fields are those of the message and, as mailsplit finds
61
+ * them, of each MIME part and each attached message inline; everything else
62
+ * is left as it is. The message itself when nothing is unfolded.
63
+ * @param {Buffer} raw
64
+ * @returns {Buffer}
65
+ */
66
+ export function unfoldHeaders(raw: Buffer): Buffer;
67
+ /**
68
+ * Parse a message, reading what can be read of one beyond mailparser's
69
+ * limits (more than 1000 MIME parts, a part's header over 1 MB): its parts
70
+ * before the 990th line that may start one (or its first half, again and
71
+ * again, when attached messages inline make the parts), and, when a header
72
+ * is too large, the message with its header lines cut down, or its first
73
+ * megabyte when the header is that of a later part. `error` is the limit,
74
+ * or null. Other errors are thrown, as earlier versions did (servers defer
75
+ * the message), and so is a parse not finished by `deadline` (ETIMEDOUT).
76
+ * @param {Buffer} raw
77
+ * @param {(raw: Buffer, options: {timeout: number}) => Promise<object>} [parse] - for tests
78
+ * @param {number} [deadline] - Date.now() milliseconds by which every try
79
+ * ends (PARSE_TIMEOUT from now)
80
+ * @returns {Promise<{mail: object, error: string|null}>}
81
+ */
82
+ export function parseWithin(raw: Buffer, parse?: (raw: Buffer, options: {
83
+ timeout: number;
84
+ }) => Promise<object>, deadline?: number): Promise<{
85
+ mail: object;
86
+ error: string | null;
87
+ }>;
88
+ /**
89
+ * A message's attachments, with those of the messages attached to it (as a
90
+ * forwarded message is), named after the message that carries them:
91
+ * "fw.eml/invoice.exe". An attached message that cannot be read (beyond the
92
+ * parser's limits, or failing it) never fails the scan: why goes to `errors`.
93
+ * @param {Array<object>} attachments - mailparser attachments
94
+ * @param {string[]} [errors] - gets why attached messages were not read whole
95
+ * @param {(raw: Buffer) => Promise<object>} [parse] - for tests
96
+ * @returns {Promise<Array<object>>}
97
+ */
98
+ export function expandAttachments(attachments?: Array<object>, errors?: string[], parse?: (raw: Buffer) => Promise<object>): Promise<Array<object>>;
99
+ /**
100
+ * DKIM-Signature fields mailauth reads of a message (see boundedForAuth): it
101
+ * checks every signature, hashing the body once for each body length (l=)
102
+ * and canonicalization and looking up each signer's key, so hundreds of them
103
+ * would hold up the process and send as many DNS queries. Real mail carries
104
+ * one to three.
105
+ */
106
+ export const MAX_DKIM_SIGNATURES: 8;
107
+ export const PARSE_TIMEOUT: 60000;
108
+ import { Buffer } from 'node:buffer';
@@ -1,11 +1,47 @@
1
+ /**
2
+ * A domain as a hostname in ASCII ("Bücher.example." is
3
+ * "xn--bcher-kva.example"), or null when it is not one: only letters, digits,
4
+ * hyphens and underscores in dot-separated labels of at most 63 characters,
5
+ * 253 in all. (A URL such as "bank.com/x.attacker.example" is not a
6
+ * hostname, though URL parsers read "bank.com" from it.)
7
+ * @param {string} value
8
+ * @returns {string|null}
9
+ */
10
+ export function asciiHostname(value: string): string | null;
1
11
  /**
2
12
  * Whether a value (IP, hostname or email address) matches a list entry. A
3
13
  * domain entry matches the domain, its subdomains and addresses at either.
14
+ *
15
+ * With `hostnamesOnly` (for an allowlist, which must trust nothing it cannot
16
+ * read), only a hostname matches a parent domain. Without it (for a
17
+ * denylist, which anything should match) any name does, such as
18
+ * "a_b!.evil.example" or one with a label of 64 characters, read from its
19
+ * last 127 labels and 253 characters, so the work is bounded however long
20
+ * the name.
4
21
  * @param {string} value
5
22
  * @param {Set<string>} list
23
+ * @param {object} [options]
24
+ * @param {boolean} [options.hostnamesOnly]
6
25
  * @returns {string|null} the entry that matched
7
26
  */
8
- export function matchList(value: string, list: Set<string>): string | null;
27
+ export function matchList(value: string, list: Set<string>, options?: {
28
+ hostnamesOnly?: boolean;
29
+ }): string | null;
30
+ /**
31
+ * Set isAllowlisted and isTruthSource of a check() result from the trusted
32
+ * values only (values that cannot be forged, see trustedReputationValues),
33
+ * in the order checked.
34
+ * @param {object} result - from ReputationChecker#check
35
+ * @param {Array<string|{value: string, signers: string[]}>} trusted -
36
+ * values, or values trusted through relaxed alignment with `signers` only
37
+ * (the aligned DKIM or SPF domains), whose entries one of them must vouch
38
+ * for
39
+ * @returns {object} a copy of the result
40
+ */
41
+ export function trustReputation(result: object, trusted?: Array<string | {
42
+ value: string;
43
+ signers: string[];
44
+ }>): object;
9
45
  /**
10
46
  * The values a message's reputation is checked by: the client IP address and
11
47
  * hostname, and the From, envelope sender and Reply-To addresses with their
@@ -15,6 +51,37 @@ export function matchList(value: string, list: Set<string>): string | null;
15
51
  * @returns {string[]}
16
52
  */
17
53
  export function reputationValues(mail?: object, session?: object): string[];
54
+ /**
55
+ * The reputation values that cannot be forged, which alone can allowlist a
56
+ * message or make it a truth source: the client IP address and its verified
57
+ * hostname (from the receiving server), a From address only when
58
+ * authentication shows its domain sent it (DMARC passes for it, or a passing
59
+ * DKIM signature is aligned with it), and the envelope sender only when SPF
60
+ * passes for its domain. Never Reply-To. A result that does not name its
61
+ * domain, or names one that is not a hostname, counts for none.
62
+ *
63
+ * Alignment is relaxed, as in DMARC (mailauth's DMARC domain is the From
64
+ * domain's registrable domain), so From values are returned as
65
+ * {value, signers}, with the aligned DKIM and SPF domains: an allowlist
66
+ * entry naming a subdomain counts only for a signer that is it, under it or
67
+ * a parent of it, of the same registrable domain. A DMARC pass for exactly
68
+ * the From domain, without alignment details, trusts it outright; one for
69
+ * its registrable domain only, registrable-domain entries. Under strict
70
+ * alignment (adkim=s, aspf=s in the domain's DMARC record) only the From
71
+ * domain itself vouches for it. A DKIM signature of part of the body (l=
72
+ * shorter than the body, mailauth's underSized) vouches for nothing, nor
73
+ * does a DMARC pass that rests on one alone, and no signature vouches for a
74
+ * From address when DMARC fails, nor for one of a From field too long to read
75
+ * whole (`mail.from.truncated`).
76
+ * @param {object} mail - parsed message
77
+ * @param {object} [session] - remoteAddress, resolvedClientHostname, envelope
78
+ * @param {object|null} [authentication] - SPF, DKIM and DMARC results
79
+ * @returns {Array<string|{value: string, signers: string[]}>}
80
+ */
81
+ export function trustedReputationValues(mail?: object, session?: object, authentication?: object | null): Array<string | {
82
+ value: string;
83
+ signers: string[];
84
+ }>;
18
85
  /**
19
86
  * Reputation lookups: local allowlists and denylists, and optionally an HTTP
20
87
  * reputation service.
@@ -24,6 +91,11 @@ export function reputationValues(mail?: object, session?: object): string[];
24
91
  * (and optional `truthSourceValue`, `allowlistValue`, `denylistValue`).
25
92
  * Values looked up: the client IP and hostname, the From, envelope sender and
26
93
  * Reply-To addresses and their domains.
94
+ *
95
+ * Any of these values can put a message on the denylist. Only values that
96
+ * cannot be forged can put it on the allowlist or make it a truth source
97
+ * (see trustedReputationValues): anyone can write any address in From or
98
+ * Reply-To.
27
99
  */
28
100
  export class ReputationChecker {
29
101
  /**
@@ -36,6 +108,8 @@ export class ReputationChecker {
36
108
  * @param {number} [options.concurrency] - parallel service requests
37
109
  * @param {number} [options.cacheTtl] - milliseconds answers are kept
38
110
  * @param {number} [options.cacheSize]
111
+ * @param {Map} [options.cache] - answers kept (shared with another checker
112
+ * of the same service)
39
113
  */
40
114
  constructor(options?: {
41
115
  allowlist?: string[];
@@ -46,6 +120,7 @@ export class ReputationChecker {
46
120
  concurrency?: number;
47
121
  cacheTtl?: number;
48
122
  cacheSize?: number;
123
+ cache?: Map<any, any>;
49
124
  });
50
125
  allowlist: Set<string>;
51
126
  denylist: Set<string>;
@@ -66,8 +141,15 @@ export class ReputationChecker {
66
141
  /**
67
142
  * Check a message's sender values against the lists and the service.
68
143
  * @param {string[]} values - IPs, hostnames, addresses
144
+ * @param {object} [options]
145
+ * @param {string[]} [options.trusted] - the values that can allowlist the
146
+ * message or make it a truth source (default: all of them); see
147
+ * trustedReputationValues
69
148
  * @returns {Promise<object>} isTruthSource, isAllowlisted, isDenylisted with
70
- * the matching values, plus checkedValues and per-value details
149
+ * the matching values, plus checkedValues, per-value details, and
150
+ * allowlisted (each value on the allowlist, with its entry)
71
151
  */
72
- check(values: string[]): Promise<object>;
152
+ check(values: string[], options?: {
153
+ trusted?: string[];
154
+ }): Promise<object>;
73
155
  }
@@ -19,7 +19,7 @@ export function bayesTestName(probability: number): string;
19
19
  * @param {object} [options.authScores] - see calculateAuthScore
20
20
  * @returns {{score: number, threshold: number, rejectThreshold: number, isSpam: boolean, action: 'accept'|'tag'|'reject', tests: Array<{name: string, score: number, description: string}>}}
21
21
  */
22
- export function scoreResults(results?: object, options?: {
22
+ export function scoreResults(results: object, options?: {
23
23
  scores?: object;
24
24
  threshold?: number;
25
25
  rejectThreshold?: number;
@@ -56,6 +56,8 @@ export namespace DEFAULT_SCORES {
56
56
  let doubleExtension: number;
57
57
  let rtlOverride: number;
58
58
  let executableInArchive: number;
59
+ let scriptInArchive: number;
60
+ let riskyAttachment: number;
59
61
  let encryptedArchive: number;
60
62
  let macro: number;
61
63
  let pdfActive: number;
@@ -63,6 +65,7 @@ export namespace DEFAULT_SCORES {
63
65
  let htmlAttachment: number;
64
66
  let activeHtmlAttachment: number;
65
67
  let virus: number;
68
+ let mimeTooComplex: number;
66
69
  let invisibleCharacters: number;
67
70
  let mixedScriptWords: number;
68
71
  let styledLetters: number;
@@ -27,33 +27,56 @@ export function sessionFromQuery(query: URLSearchParams): object;
27
27
  * - POST /learn/spam, /learn/ham → teach the classifier (only with a token)
28
28
  *
29
29
  * The request body is the raw message. Query parameters: ip, hostname, helo,
30
- * from, to (see sessionFromQuery), and for /check, subjectTag.
30
+ * from, to (see sessionFromQuery), and for /check, subjectTag. Authentication
31
+ * (SPF, DKIM, DMARC, ARC) and IP blocklists run only when ip is given.
31
32
  *
32
33
  * @param {import('./index.js').SpamScanner} scanner
33
34
  * @param {object} [options]
34
35
  * @param {string} [options.token] - required as "Authorization: Bearer <token>" when set
35
36
  * @param {number} [options.maxSize] - largest message accepted, in bytes
36
37
  * @param {string} [options.modelPath] - where /learn saves the classifier
38
+ * @param {number} [options.maxConnections] - connections kept open, and requests handled, at once
37
39
  * @returns {http.Server}
38
40
  */
39
41
  export function createHttpServer(scanner: import("./index.js").SpamScanner, options?: {
40
42
  token?: string;
41
43
  maxSize?: number;
42
44
  modelPath?: string;
45
+ maxConnections?: number;
43
46
  }): http.Server;
44
47
  /**
45
48
  * A plain TCP server: a client sends a raw message and closes its side; the
46
49
  * server answers with the result as one line of JSON (or a short text line)
47
50
  * and closes the connection.
51
+ *
52
+ * The client's IP address is not known, so authentication (SPF, DKIM, DMARC,
53
+ * ARC) and IP blocklists do not run for these scans.
48
54
  * @param {import('./index.js').SpamScanner} scanner
49
55
  * @param {object} [options]
50
56
  * @param {boolean} [options.json] - answer in JSON (default true)
51
57
  * @param {number} [options.maxSize]
58
+ * @param {number} [options.timeout] - close a connection that sends nothing for this many milliseconds (default 60000)
59
+ * @param {number} [options.maxConnections] - connections kept open at once; more are closed at once
52
60
  * @returns {net.Server}
53
61
  */
54
62
  export function createTcpServer(scanner: import("./index.js").SpamScanner, options?: {
55
63
  json?: boolean;
56
64
  maxSize?: number;
65
+ timeout?: number;
66
+ maxConnections?: number;
57
67
  }): net.Server;
68
+ /**
69
+ * The largest message the servers and the command line read by default, in
70
+ * bytes (25 MiB): above Postfix's default message_size_limit (10 MB) and the
71
+ * limits of most mail services.
72
+ */
73
+ export const MAX_MESSAGE_SIZE: number;
74
+ /**
75
+ * The most connections each server keeps open at once by default (and, for
76
+ * the HTTP API, requests it handles at once): each may hold a message of up
77
+ * to MAX_MESSAGE_SIZE bytes while it is scanned. Postfix runs at most 100
78
+ * smtpd and 100 cleanup processes by default, each with one milter connection.
79
+ */
80
+ export const MAX_CONNECTIONS: 200;
58
81
  import http from 'node:http';
59
82
  import net from 'node:net';
@@ -19,7 +19,9 @@ export function parseCsvLine(line: string): string[] | null;
19
19
  */
20
20
  export function normalizeLabel(value: unknown): "spam" | "ham" | null;
21
21
  /**
22
- * Read labelled rows from a CSV file with a header row.
22
+ * Read labelled rows from a CSV file with a header row. A quoted field may
23
+ * span lines; a record still open after 10,000 lines or 1 MiB (a stray
24
+ * quote) is dropped, and reading goes on with the next line.
23
25
  * @param {string} file
24
26
  * @param {object} [options]
25
27
  * @param {string} [options.textColumn] - column holding the message (default: text, message, body, email text...)
@@ -37,9 +39,11 @@ export function readCsv(file: string, options?: {
37
39
  label: "spam" | "ham";
38
40
  }>;
39
41
  /**
40
- * Read labelled rows from a JSON Lines file: one object per line with a text
41
- * field ("text", "message", "body") and a label field ("label", "label_text",
42
- * "is_spam", "category").
42
+ * Read labelled rows from a JSON Lines file: one object per line, with a text
43
+ * field and a label field found by the same names as readCsv's columns, in
44
+ * any case ("label_text" before "label", and "content" before "email"), the
45
+ * first of them that holds text or a label. A line that is not a JSON object
46
+ * is skipped.
43
47
  * @param {string} file
44
48
  * @param {object} [options] - textColumn, labelColumn, subjectColumn as for readCsv
45
49
  * @returns {AsyncGenerator<{text: string, subject: string, label: 'spam'|'ham'}>}
@@ -52,7 +56,8 @@ export function readJsonl(file: string, options?: object): AsyncGenerator<{
52
56
  /**
53
57
  * Read raw messages from a path: an .eml file, an mbox file, or a directory
54
58
  * of messages (one per file, such as a Maildir or a folder of .eml files,
55
- * searched recursively; mbox files inside are read too).
59
+ * searched recursively; mbox files inside are read too). A gzip file is read
60
+ * only as an mbox file (named .mbox.gz); other .gz files are skipped.
56
61
  * @param {string} source
57
62
  * @returns {AsyncGenerator<string|Buffer>}
58
63
  */
@@ -33,12 +33,17 @@ export function answer(scanner: import("./index.js").SpamScanner, request: {
33
33
  * Supports CHECK, SYMBOLS, REPORT, REPORT_IFSPAM, PROCESS, HEADERS, PING,
34
34
  * SKIP and (with allowTell) TELL. Compressed requests are refused.
35
35
  *
36
+ * spamd clients do not send the client's IP address, so authentication
37
+ * (SPF, DKIM, DMARC, ARC) and IP blocklists do not run for these scans.
38
+ *
36
39
  * @param {import('./index.js').SpamScanner} scanner
37
40
  * @param {object} [options]
38
41
  * @param {number} [options.maxSize] - largest message accepted, in bytes
39
42
  * @param {boolean} [options.allowTell] - accept TELL (learning) requests
40
43
  * @param {string} [options.modelPath] - where TELL saves the classifier
41
44
  * @param {string|null} [options.subjectTag] - tag for spam in PROCESS and HEADERS answers
45
+ * @param {number} [options.timeout] - close a connection that sends nothing for this many milliseconds (default 60000)
46
+ * @param {number} [options.maxConnections] - connections kept open at once; more are closed at once
42
47
  * @returns {net.Server}
43
48
  */
44
49
  export function createSpamdServer(scanner: import("./index.js").SpamScanner, options?: {
@@ -46,6 +51,8 @@ export function createSpamdServer(scanner: import("./index.js").SpamScanner, opt
46
51
  allowTell?: boolean;
47
52
  modelPath?: string;
48
53
  subjectTag?: string | null;
54
+ timeout?: number;
55
+ maxConnections?: number;
49
56
  }): net.Server;
50
57
  export namespace SPAMD_EXIT_CODES {
51
58
  export { EX_OK };
@@ -7,6 +7,14 @@
7
7
  * @returns {RegExp}
8
8
  */
9
9
  export function characterClass(ranges: number[][], flags?: string): RegExp;
10
+ /**
11
+ * The address a link goes to, as a URL: through a security gateway, the
12
+ * address it goes on to (see gatewayTarget), as many as three wrapped in
13
+ * one another; or null for an address that is no URL.
14
+ * @param {string} address
15
+ * @returns {URL|null}
16
+ */
17
+ export function unwrapLink(address: string): URL | null;
10
18
  /**
11
19
  * Split text into pieces of at most `size` characters, at whitespace or
12
20
  * punctuation where possible.
@@ -86,37 +94,40 @@ export function obfuscationOf(text: string, options?: {
86
94
  * @returns {string}
87
95
  */
88
96
  export function registrableDomain(host: string): string;
97
+ export function extractLinks(text: any, html: any, { maxLength }?: {
98
+ maxLength?: number;
99
+ }): any[];
89
100
  /**
90
- * Find the links in plain text and HTML. For each link, the host, and for
91
- * HTML links the visible text when it is itself a web address.
92
- * @param {string} text
93
- * @param {string} html
94
- * @returns {Array<{url: string, host: string, text: string|null}>}
101
+ * The value of the first href attribute of a tag ("<a ...>"), or null:
102
+ * read attribute by attribute, as browsers read them, so an "href=" inside
103
+ * the value of another attribute is not taken for one. In one pass.
104
+ * @param {string} tag
105
+ * @returns {string|null}
95
106
  */
96
- export function extractLinks(text: string, html: string): Array<{
97
- url: string;
98
- host: string;
99
- text: string | null;
100
- }>;
107
+ export function hrefOf(tag: string): string | null;
101
108
  /**
102
109
  * The end of the tag that starts at `start` (just after its ">"), or -1 when
103
110
  * no ">" follows. A quoted attribute may hold ">" ("?a=>b"); a "<" outside
104
- * quotes is part of the tag, as browsers read it. A quote not closed within
105
- * 6000 characters is not one: then the tag ends at the next ">".
111
+ * quotes is part of the tag, as browsers read it. A quote opens a value only
112
+ * after "=" (in `href=https://a.example/"x` it is part of the value). A quote
113
+ * not closed within 6000 characters is not one: then the tag ends at the next
114
+ * ">".
106
115
  * @param {string} html
107
116
  * @param {number} start
108
117
  * @returns {number}
109
118
  */
110
119
  export function tagEnd(html: string, start: number): number;
111
120
  /**
112
- * The links of HTML: each <a> tag with an href (up to 4000 characters, in a
113
- * tag of up to 6000), and its text without tags when "</a>" follows within
114
- * 2000 characters.
121
+ * The links of HTML: each <a> tag with an href, and the text it shows (see
122
+ * visibleText: without hidden text, and here without invisible characters)
123
+ * when "</a>" or another <a> tag follows within 2000 characters (otherwise
124
+ * no text: a mail client shows a link never closed, or around a long image
125
+ * or table, all the same).
115
126
  *
116
- * Read in about one pass: a tag is read up to its end, and the next tag
117
- * looked for after it, and the closing tags are found with one search, so
118
- * the HTML is not read again for each tag. (A tag whose quote is not closed
119
- * may read up to 6000 characters, but another such tag within them needs the
127
+ * Read in about one pass: a tag is read up to its end, the tags of a link's
128
+ * text up to its "</a>", and the next <a> tag looked for after them, so the
129
+ * HTML is not read again for each tag. (A tag whose quote is not closed may
130
+ * read up to 6000 characters, but another such tag within them needs the
120
131
  * other quote mark, so at most two do.)
121
132
  * @param {string} html
122
133
  * @returns {Array<{href: string, inner: string}>}
@@ -129,8 +140,9 @@ export function anchors(html: string): Array<{
129
140
  * Host names in text that are not links: written without "http://"
130
141
  * ("paypa1-secure.example") or the domain of an email address. Only names
131
142
  * under a real public suffix count ("node.js" and "file.txt" do not), written
132
- * in lowercase as addresses are (so "end.Click" in text missing a space is
133
- * not taken for one), not the local part of an address, and at most 50.
143
+ * in lowercase as addresses are or all in capitals ("PAYPAL-VERIFY.COM", read
144
+ * in lowercase; so "end.Click" in text missing a space is not taken for one),
145
+ * not the local part of an address, and at most 50.
134
146
  * These are for lookalike checks, not classifier features.
135
147
  * @param {string} text
136
148
  * @param {Set<string>} [known] - hosts of the links already found
@@ -139,13 +151,28 @@ export function anchors(html: string): Array<{
139
151
  export function extractHosts(text: string, known?: Set<string>): string[];
140
152
  /**
141
153
  * Whether the visible text of a link names a different site than the link
142
- * goes to ("https://paypal.com" linking to another domain).
143
- * @param {{host: string, text: string|null}} link
154
+ * goes to ("https://paypal.com" linking to another domain). A link through an
155
+ * email service's click tracking (see CLICK_TRACKERS) goes elsewhere by
156
+ * design, so it counts only when the sender is known (`from`, the domain of
157
+ * the From address, or "" for none), the text shows a whole address with
158
+ * "http://" or "https://" of a brand's own domain (see BRANDS; not one that
159
+ * newsletters link to, see LINKED_BRANDS) with no path or one that asks to
160
+ * sign in or pay, and the sender is not that brand (nor one of its sending
161
+ * domains, "facebookmail.com", see SENDING_DOMAINS): a hijacked account of a mailing service
162
+ * sending "https://www.paypal.com" to a phishing page, not a newsletter's
163
+ * links. A link through a security gateway is read as the address the
164
+ * gateway goes on to, where the gateway's link holds it (see unwrapLink).
165
+ * @param {{host: string, text: string|null, url?: string}} link
166
+ * @param {object} [options]
167
+ * @param {string} [options.from] - the sender's domain
144
168
  * @returns {boolean}
145
169
  */
146
170
  export function isDeceptiveLink(link: {
147
171
  host: string;
148
172
  text: string | null;
173
+ url?: string;
174
+ }, { from }?: {
175
+ from?: string;
149
176
  }): boolean;
150
177
  /**
151
178
  * Classifier features of a message parsed by mailparser.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spamscanner",
3
3
  "description": "Spam, phishing and malware filter for email in any language: a trainable classifier, link and attachment checks, SPF/DKIM/DMARC, and an optional AI second opinion from any LLM, including local Ollama models. Works as a library, CLI, Postfix milter or HTTP API.",
4
- "version": "7.0.3",
4
+ "version": "7.0.5",
5
5
  "author": "Forward Email (https://forwardemail.net)",
6
6
  "bin": {
7
7
  "spamscanner": "./dist/esm/cli.js"
@@ -37,6 +37,7 @@
37
37
  "devDependencies": {
38
38
  "@commitlint/cli": "^19.8.1",
39
39
  "@commitlint/config-conventional": "^19.8.1",
40
+ "@types/node": "^18.19.130",
40
41
  "c8": "^10.1.3",
41
42
  "esbuild": "^0.28.2",
42
43
  "fixpack": "^4.0.0",
@@ -56,19 +57,31 @@
56
57
  },
57
58
  "exports": {
58
59
  ".": {
59
- "types": "./dist/types/index.d.ts",
60
- "import": "./dist/esm/index.js",
61
- "require": "./dist/cjs/index.cjs"
60
+ "import": {
61
+ "types": "./dist/types/index.d.ts",
62
+ "default": "./dist/esm/index.js"
63
+ },
64
+ "require": {
65
+ "types": "./dist/types/index.d.cts",
66
+ "default": "./dist/cjs/index.cjs"
67
+ }
62
68
  },
63
69
  "./arf": {
64
- "types": "./dist/types/arf.d.ts",
65
- "import": "./dist/esm/arf.js",
66
- "require": "./dist/cjs/arf.cjs"
70
+ "import": {
71
+ "types": "./dist/types/arf.d.ts",
72
+ "default": "./dist/esm/arf.js"
73
+ },
74
+ "require": {
75
+ "types": "./dist/types/arf.d.cts",
76
+ "default": "./dist/cjs/arf.cjs"
77
+ }
67
78
  },
68
79
  "./package.json": "./package.json"
69
80
  },
70
81
  "files": [
71
82
  "dist/",
83
+ "!dist/standalone/",
84
+ "!dist/types/cli.d.ts",
72
85
  "model/"
73
86
  ],
74
87
  "homepage": "https://spamscanner.net",
@@ -114,7 +127,7 @@
114
127
  "type": "module",
115
128
  "types": "./dist/types/index.d.ts",
116
129
  "scripts": {
117
- "build": "node build.mjs && tsc --project tsconfig.json",
130
+ "build": "node build.mjs",
118
131
  "i18n:check": "node scripts/i18n-check.js",
119
132
  "lint": "fixpack && remark . -qfo && xo --fix",
120
133
  "model:train": "node scripts/train-model.js",