@postalsys/otp-detect 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Postal Systems OÜ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # otp-detect
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Finds the one-time code (login, verification, two-factor and similar codes) in an email message.
4
+
5
+ Senders can put the code in an `OTP-Token` header as described in
6
+ [draft-goto-otp-token](https://datatracker.ietf.org/doc/draft-goto-otp-token/), and
7
+ [postal-mime](https://github.com/postalsys/postal-mime) already exposes that header as `message.otp`.
8
+ Most senders do not set it yet, so this package looks for the code in the message body instead, using
9
+ a small trained model.
10
+
11
+ **Status:** early development, not published yet.
12
+
13
+ ## Usage
14
+
15
+ ```js
16
+ import { detectOtp } from '@postalsys/otp-detect';
17
+
18
+ const result = detectOtp({ subject, text, html });
19
+ // { code: '739104', raw: '739 104', score: 0.98, source: 'html' } or null
20
+ ```
21
+
22
+ The input is an already parsed message: `subject`, the `text/plain` body and the `text/html` body,
23
+ any of them optional. With [postal-mime](https://github.com/postalsys/postal-mime):
24
+
25
+ ```js
26
+ const email = await PostalMime.parse(raw);
27
+ const code = email.otp?.code ?? detectOtp(email)?.code;
28
+ ```
29
+
30
+ Prefer the `OTP-Token` header when there is one. Neither the header nor the body is authenticated:
31
+ check that the message passes DKIM for the sender's domain before offering a code to a user.
32
+
33
+ `scoreCandidates(input)` returns every code-like token with its score, best first, for debugging or
34
+ custom rules. Both functions take `{ threshold }` to override the default threshold (0.97).
35
+
36
+ ## How it works
37
+
38
+ 1. **Rendering.** The HTML body is rendered to text while remembering how each piece of text looks:
39
+ font size, bold, letter spacing, links, hidden preheaders, quoted reply history.
40
+ 2. **Candidates.** Code-like tokens are extracted from the rendered HTML and the plain text: digit
41
+ runs, grouped digits (`123 456`), capital letter codes (`QWKZT`), mixed alphanumeric codes.
42
+ 3. **Scoring.** Gradient boosted trees score each candidate from its context: the distance to words
43
+ that name a code in many languages, to words that name order numbers, prices and promo codes,
44
+ its styling and position, and message-level signals such as the subject. The best candidate above
45
+ the threshold is returned, or nothing.
46
+
47
+ The model is a few dozen kilobytes of trees embedded in the package, and runs in plain JavaScript with
48
+ no runtime dependencies. Work is linear in the size of the message, which is cut at 512 KB per body.
49
+
50
+ ## Accuracy
51
+
52
+ Measured on real mail with cross-validation grouped by sender domain, so every score is for senders
53
+ the model never saw in training, averaged over three shuffles, at the default threshold:
54
+
55
+ | emails | result |
56
+ | -------------------------------------------------------------- | ------------------------------ |
57
+ | 324 emails with a one-time code | 67.3% correct, no wrong codes |
58
+ | 2,045 random emails | none answered |
59
+ | 8,862 hard negatives (order numbers, promo codes, magic links) | 0.9% answered (false positive) |
60
+
61
+ The real training mail is mostly English and Estonian. Synthetic emails from fictional services in
62
+ 17 languages fill the gap: on 300 held-out synthetic emails the detector finds 97.5% of the codes
63
+ with no wrong codes. Synthetic emails come from the same generator as the training ones, so treat that
64
+ figure as an upper bound for real mail in those languages.
65
+
66
+ ## Repository layout
67
+
68
+ | path | contents |
69
+ | ----------- | ------------------------------------------------------------------------------- |
70
+ | `src/` | The library: HTML rendering, candidates, keywords, features, tree evaluation |
71
+ | `scripts/` | Feature dump for training and a corpus scanner (`dump-features`, `scan-corpus`) |
72
+ | `training/` | Python pipeline: corpus prefilter, LLM labeling, synthetic emails, training |
73
+
74
+ Training data is derived from private mail and lives in a separate private repository. Nothing in
75
+ this repository contains message content; test fixtures are written by hand.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,18 @@
1
+ import type { Candidate } from './candidates.js';
2
+ import type { Doc } from './doc.js';
3
+ export type MessageInput = {
4
+ subject?: string | undefined;
5
+ /** The text/plain body */
6
+ text?: string | undefined;
7
+ /** The text/html body */
8
+ html?: string | undefined;
9
+ };
10
+ export type Occurrence = {
11
+ candidate: Candidate;
12
+ source: Doc['source'];
13
+ features: number[];
14
+ };
15
+ /** Bodies larger than this are cut; code notifications are a few kilobytes */
16
+ export declare const MAX_BODY_CHARS: number;
17
+ export declare function renderDocs(input: MessageInput): Doc[];
18
+ export declare function analyze(input: MessageInput): Occurrence[];
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ // Shared by the library and the training data dump, so that both compute features the same way.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.MAX_BODY_CHARS = void 0;
5
+ exports.renderDocs = renderDocs;
6
+ exports.analyze = analyze;
7
+ const candidates_js_1 = require("./candidates.js");
8
+ const doc_js_1 = require("./doc.js");
9
+ const features_js_1 = require("./features.js");
10
+ const html_js_1 = require("./html.js");
11
+ /** Bodies larger than this are cut; code notifications are a few kilobytes */
12
+ exports.MAX_BODY_CHARS = 512 * 1024;
13
+ function renderDocs(input) {
14
+ const docs = [];
15
+ if (input.html) {
16
+ docs.push((0, html_js_1.renderHtml)(input.html.slice(0, exports.MAX_BODY_CHARS)));
17
+ }
18
+ if (input.text) {
19
+ docs.push((0, doc_js_1.renderText)(input.text.slice(0, exports.MAX_BODY_CHARS)));
20
+ }
21
+ return docs.filter(d => d.text.length > 0);
22
+ }
23
+ function analyze(input) {
24
+ const docs = renderDocs(input);
25
+ const perDoc = docs.map(doc => ({ doc, candidates: (0, candidates_js_1.extractCandidates)(doc) }));
26
+ const distinctCodes = new Set(perDoc.flatMap(d => d.candidates.map(c => c.code))).size;
27
+ const subject = (input.subject ?? '').slice(0, 1000);
28
+ const out = [];
29
+ for (const { doc, candidates } of perDoc) {
30
+ const ctx = (0, features_js_1.docContext)(doc, candidates);
31
+ const otherText = docs
32
+ .filter(d => d !== doc)
33
+ .map(d => d.text)
34
+ .join('\n');
35
+ const msg = (0, features_js_1.messageContext)(subject, otherText, distinctCodes);
36
+ candidates.forEach((candidate, i) => {
37
+ out.push({ candidate, source: doc.source, features: (0, features_js_1.candidateFeatures)(ctx, msg, i) });
38
+ });
39
+ }
40
+ return out;
41
+ }
@@ -0,0 +1,14 @@
1
+ import type { Doc } from './doc.js';
2
+ export type CandidateKind = 'token' | 'dashed-part' | 'grouped' | 'spaced';
3
+ export type Candidate = {
4
+ /** The code as it would be typed: separators inside digit groups and spaced letters removed */
5
+ code: string;
6
+ /** The text as it appears in the document */
7
+ raw: string;
8
+ start: number;
9
+ end: number;
10
+ kind: CandidateKind;
11
+ };
12
+ /** Whether a token without separators looks like a code */
13
+ export declare function codeLike(core: string): boolean;
14
+ export declare function extractCandidates(doc: Doc): Candidate[];
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ // Finds every token in a document that could be a one-time code. This step is deliberately
3
+ // generous: a code it misses can never be found, while a wrong candidate only costs the scorer
4
+ // one more row to reject.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.codeLike = codeLike;
7
+ exports.extractCandidates = extractCandidates;
8
+ const TOKEN_RE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/g;
9
+ // Single characters separated by single spaces or line breaks: "4 8 2 9 1 7", or one digit per table cell
10
+ const SPACED_RE = /(?<![A-Za-z0-9])[A-Z0-9](?:[ \n][A-Z0-9]){3,9}(?![A-Za-z0-9])/g;
11
+ // Characters that glue a token to its neighbour into something that is not a code: 12.50, 10:30,
12
+ // 1,000, a/b, user@host, snake_case
13
+ const JOINERS = new Set(['.', ',', ':', '/', '@', '_', '\\', '=', '&', '?', '%', '+']);
14
+ const ALNUM_RE = /[A-Za-z0-9]/;
15
+ const MAX_CANDIDATES = 400;
16
+ function isAlnum(ch) {
17
+ return ch !== undefined && ALNUM_RE.test(ch);
18
+ }
19
+ /** Whether a token without separators looks like a code */
20
+ function codeLike(core) {
21
+ const len = core.length;
22
+ if (len > 12 && len <= 40) {
23
+ // Long random validation codes mix upper case, lower case and digits (domain validation)
24
+ return /[A-Z]/.test(core) && /[a-z]/.test(core) && /\d/.test(core) && /^[A-Za-z0-9]+$/.test(core);
25
+ }
26
+ if (len < 4 || len > 12) {
27
+ return false;
28
+ }
29
+ if (/^\d+$/.test(core)) {
30
+ return len <= 10;
31
+ }
32
+ const hasDigit = /\d/.test(core);
33
+ if (!hasDigit) {
34
+ // Letter-only codes are printed in capitals (Steam Guard style: QWKZT)
35
+ return /^[A-Z]+$/.test(core) && len <= 10;
36
+ }
37
+ if (!/[A-Za-z]/.test(core)) {
38
+ return false;
39
+ }
40
+ // 21st, 100mg, 1080p, 12px, 5000mAh
41
+ return !/^\d+(?:[a-z]{1,3}|mAh|MB|GB|KB|kB|Hz)$/.test(core);
42
+ }
43
+ // Checked once per whitespace separated chunk, never per token, so a long blob stays linear
44
+ const URL_OR_ADDRESS_RE = /:\/\/|^www\.|@[A-Za-z0-9-]+\.|\.[a-z]{2,6}\//i;
45
+ const CHUNK_RE = /\S+/g;
46
+ function gluedToNeighbour(text, start, end) {
47
+ const before = text[start - 1];
48
+ const after = text[end];
49
+ if (before !== undefined && JOINERS.has(before) && isAlnum(text[start - 2])) {
50
+ return true;
51
+ }
52
+ return after !== undefined && JOINERS.has(after) && isAlnum(text[end + 1]);
53
+ }
54
+ function make(text, start, end, kind, code) {
55
+ return { code, raw: text.slice(start, end), start, end, kind };
56
+ }
57
+ function tokenCandidates(text, out) {
58
+ const tokens = [];
59
+ CHUNK_RE.lastIndex = 0;
60
+ for (let chunk = CHUNK_RE.exec(text); chunk; chunk = CHUNK_RE.exec(text)) {
61
+ if (URL_OR_ADDRESS_RE.test(chunk[0])) {
62
+ continue;
63
+ }
64
+ TOKEN_RE.lastIndex = 0;
65
+ for (let m = TOKEN_RE.exec(chunk[0]); m; m = TOKEN_RE.exec(chunk[0])) {
66
+ const start = chunk.index + m.index;
67
+ const end = start + m[0].length;
68
+ if (!gluedToNeighbour(text, start, end)) {
69
+ tokens.push({ start, end, value: m[0] });
70
+ addToken(text, start, m[0], out);
71
+ }
72
+ }
73
+ }
74
+ addDigitGroups(text, tokens, out);
75
+ }
76
+ function addToken(text, start, value, out) {
77
+ const end = start + value.length;
78
+ if (!value.includes('-')) {
79
+ if (codeLike(value)) {
80
+ out.push(make(text, start, end, 'token', value));
81
+ }
82
+ return;
83
+ }
84
+ const parts = value.split('-');
85
+ const joined = parts.join('');
86
+ if (/^\d+$/.test(joined)) {
87
+ // 123-456, 12-34-56: typed without the dashes. Other digit runs are phone numbers and dates
88
+ if (parts.every(p => p.length >= 2 && p.length <= 4) && codeLike(joined)) {
89
+ out.push(make(text, start, end, 'grouped', joined));
90
+ }
91
+ return;
92
+ }
93
+ if (joined.length >= 4 && value.length <= 40 && /\d/.test(joined) && /[A-Za-z]/.test(joined)) {
94
+ // ABCD-1234, G-123456, 4a7c01e9-82f3b6d0: keys printed with their dashes. Digit-only runs
95
+ // that are not two to four even groups are phone numbers and dates (372-5-555-0100)
96
+ out.push(make(text, start, end, 'token', value));
97
+ }
98
+ let offset = start;
99
+ for (const part of parts) {
100
+ if (codeLike(part)) {
101
+ out.push(make(text, offset, offset + part.length, 'dashed-part', part));
102
+ }
103
+ offset += part.length + 1;
104
+ }
105
+ }
106
+ // "123 456" and "1234 5678": two or three digit groups separated by a single space
107
+ function addDigitGroups(text, tokens, out) {
108
+ for (let i = 0; i < tokens.length; i++) {
109
+ let digits = '';
110
+ for (let j = i; j < tokens.length && j < i + 3; j++) {
111
+ const token = tokens[j];
112
+ const separatedBySpace = j === i || (token.start === tokens[j - 1].end + 1 && text[token.start - 1] === ' ');
113
+ if (!/^\d{2,4}$/.test(token.value) || !separatedBySpace) {
114
+ break;
115
+ }
116
+ digits += token.value;
117
+ if (j > i && digits.length >= 6 && digits.length <= 9) {
118
+ out.push(make(text, tokens[i].start, token.end, 'grouped', digits));
119
+ }
120
+ }
121
+ }
122
+ }
123
+ function spacedCandidates(text, out) {
124
+ SPACED_RE.lastIndex = 0;
125
+ for (let m = SPACED_RE.exec(text); m; m = SPACED_RE.exec(text)) {
126
+ const code = m[0].replace(/[ \n]/g, '');
127
+ if (/\d/.test(code)) {
128
+ out.push(make(text, m.index, m.index + m[0].length, 'spaced', code));
129
+ }
130
+ }
131
+ }
132
+ function extractCandidates(doc) {
133
+ const out = [];
134
+ tokenCandidates(doc.text, out);
135
+ spacedCandidates(doc.text, out);
136
+ out.sort((a, b) => a.start - b.start || b.end - a.end);
137
+ return out.slice(0, MAX_CANDIDATES);
138
+ }
@@ -0,0 +1,52 @@
1
+ export declare const DEFAULT_FONT_SIZE = 16;
2
+ export type Style = {
3
+ fontSize: number;
4
+ bold: boolean;
5
+ letterSpacing: boolean;
6
+ link: boolean;
7
+ heading: boolean;
8
+ mono: boolean;
9
+ hidden: boolean;
10
+ /** Inside quoted reply history or a forwarded message */
11
+ quote: boolean;
12
+ };
13
+ export declare const DEFAULT_STYLE: Readonly<Style>;
14
+ export type Span = {
15
+ start: number;
16
+ end: number;
17
+ };
18
+ export type StyledSpan = Span & {
19
+ style: Readonly<Style>;
20
+ };
21
+ export type Doc = {
22
+ source: 'html' | 'text';
23
+ text: string;
24
+ /** Consecutive, non-overlapping, sorted by start; text between spans is whitespace */
25
+ spans: StyledSpan[];
26
+ /** Every block, innermost ones included, sorted by start */
27
+ blocks: Span[];
28
+ /** Offset where reply history or a forwarded message starts, text.length if none */
29
+ quoteStart: number;
30
+ };
31
+ export declare class DocBuilder {
32
+ private parts;
33
+ private length;
34
+ private last;
35
+ private spans;
36
+ private blocks;
37
+ private openBlocks;
38
+ private readonly source;
39
+ constructor(source: 'html' | 'text');
40
+ private append;
41
+ text(raw: string, style: Readonly<Style>): void;
42
+ private dropTrailingSpace;
43
+ newline(): void;
44
+ openBlock(): void;
45
+ closeBlock(): void;
46
+ finish(): Doc;
47
+ }
48
+ export declare function renderText(input: string): Doc;
49
+ /** The style at a text offset; whitespace between spans takes the style of the span before it */
50
+ export declare function styleAt(doc: Doc, offset: number): Readonly<Style>;
51
+ /** The smallest block that contains the whole range */
52
+ export declare function blockAround(doc: Doc, start: number, end: number): Span;
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ // The common representation both the HTML and the plain text body are turned into: visible text,
3
+ // the style of every stretch of it, and the blocks (paragraphs, table cells, lines) it is split in.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.DocBuilder = exports.DEFAULT_STYLE = exports.DEFAULT_FONT_SIZE = void 0;
6
+ exports.renderText = renderText;
7
+ exports.styleAt = styleAt;
8
+ exports.blockAround = blockAround;
9
+ exports.DEFAULT_FONT_SIZE = 16;
10
+ exports.DEFAULT_STYLE = Object.freeze({
11
+ fontSize: exports.DEFAULT_FONT_SIZE,
12
+ bold: false,
13
+ letterSpacing: false,
14
+ link: false,
15
+ heading: false,
16
+ mono: false,
17
+ hidden: false,
18
+ quote: false
19
+ });
20
+ const SPACE_RE = /[\s\u00a0\u200b\u200c\u200d\u2028\u2029\ufeff]+/g;
21
+ // "On Tue, 1 Jan 2025 John wrote:", Outlook and forward separators, and the same in Estonian
22
+ const ATTRIBUTION_RE = /^(?:-{2,}\s*(?:original message|forwarded message|edastatud kiri|algne kiri)\s*-{2,}|-{5,}|on .{3,200}(?:wrote|kirjutas):|.{3,120}kirjutas(?: .{0,80})?:|from: .{3,200}\n(?:sent|date): )/im;
23
+ class DocBuilder {
24
+ constructor(source) {
25
+ // The output is kept as parts plus its length and last character: reading back a string that
26
+ // is being appended to makes V8 flatten it on every call, which is quadratic on large bodies
27
+ this.parts = [];
28
+ this.length = 0;
29
+ this.last = '';
30
+ this.spans = [];
31
+ this.blocks = [];
32
+ this.openBlocks = [];
33
+ this.source = source;
34
+ }
35
+ append(chunk) {
36
+ this.parts.push(chunk);
37
+ this.length += chunk.length;
38
+ this.last = chunk[chunk.length - 1];
39
+ }
40
+ text(raw, style) {
41
+ const collapsed = raw.replace(SPACE_RE, ' ');
42
+ const atLineStart = this.length === 0 || this.last === '\n' || this.last === ' ';
43
+ const chunk = atLineStart && collapsed.startsWith(' ') ? collapsed.slice(1) : collapsed;
44
+ if (!chunk) {
45
+ return;
46
+ }
47
+ const start = this.length;
48
+ this.append(chunk);
49
+ const span = this.spans[this.spans.length - 1];
50
+ if (span && span.style === style && span.end === start) {
51
+ span.end = this.length;
52
+ }
53
+ else {
54
+ this.spans.push({ start, end: this.length, style });
55
+ }
56
+ }
57
+ dropTrailingSpace() {
58
+ if (this.last !== ' ') {
59
+ return;
60
+ }
61
+ const part = this.parts.pop();
62
+ const rest = part.slice(0, -1);
63
+ this.length--;
64
+ if (rest) {
65
+ this.parts.push(rest);
66
+ }
67
+ const prev = this.parts[this.parts.length - 1];
68
+ this.last = prev ? prev[prev.length - 1] : '';
69
+ const span = this.spans[this.spans.length - 1];
70
+ if (span && span.end > this.length) {
71
+ span.end = this.length;
72
+ }
73
+ }
74
+ newline() {
75
+ this.dropTrailingSpace();
76
+ if (this.length && this.last !== '\n') {
77
+ this.append('\n');
78
+ }
79
+ }
80
+ openBlock() {
81
+ this.newline();
82
+ this.openBlocks.push(this.length);
83
+ }
84
+ closeBlock() {
85
+ const start = this.openBlocks.pop();
86
+ if (start === undefined) {
87
+ return;
88
+ }
89
+ // Text is collapsed as it is added, so at most one whitespace character trails it
90
+ const end = this.length - (this.last === '\n' || this.last === ' ' ? 1 : 0);
91
+ if (end > start) {
92
+ this.blocks.push({ start, end });
93
+ }
94
+ this.newline();
95
+ }
96
+ finish() {
97
+ while (this.openBlocks.length) {
98
+ this.closeBlock();
99
+ }
100
+ const text = this.parts.join('').replace(/\s+$/, '');
101
+ const spans = this.spans.filter(s => s.start < text.length).map(s => ({ ...s, end: Math.min(s.end, text.length) }));
102
+ // Every line is a block too, which covers plain text and HTML without block elements
103
+ for (const line of text.matchAll(/[^\n]+/g)) {
104
+ this.blocks.push({ start: line.index, end: line.index + line[0].length });
105
+ }
106
+ this.blocks.sort((a, b) => a.start - b.start || b.end - a.end);
107
+ const attribution = ATTRIBUTION_RE.exec(text);
108
+ return { source: this.source, text, spans, blocks: this.blocks, quoteStart: attribution ? attribution.index : text.length };
109
+ }
110
+ }
111
+ exports.DocBuilder = DocBuilder;
112
+ const QUOTED_STYLE = Object.freeze({ ...exports.DEFAULT_STYLE, quote: true });
113
+ function renderText(input) {
114
+ const doc = new DocBuilder('text');
115
+ for (const line of input.replace(/\r\n?/g, '\n').split('\n')) {
116
+ doc.text(line, /^\s*>/.test(line) ? QUOTED_STYLE : exports.DEFAULT_STYLE);
117
+ doc.newline();
118
+ }
119
+ return doc.finish();
120
+ }
121
+ /** The style at a text offset; whitespace between spans takes the style of the span before it */
122
+ function styleAt(doc, offset) {
123
+ let lo = 0;
124
+ let hi = doc.spans.length - 1;
125
+ let found;
126
+ while (lo <= hi) {
127
+ const mid = Math.floor((lo + hi) / 2);
128
+ const span = doc.spans[mid];
129
+ if (span.start <= offset) {
130
+ found = span;
131
+ lo = mid + 1;
132
+ }
133
+ else {
134
+ hi = mid - 1;
135
+ }
136
+ }
137
+ return found?.style ?? exports.DEFAULT_STYLE;
138
+ }
139
+ /** The smallest block that contains the whole range */
140
+ function blockAround(doc, start, end) {
141
+ let best = { start: 0, end: doc.text.length };
142
+ for (const block of doc.blocks) {
143
+ if (block.start > start) {
144
+ break;
145
+ }
146
+ if (block.end >= end && block.end - block.start < best.end - best.start) {
147
+ best = block;
148
+ }
149
+ }
150
+ return best;
151
+ }
@@ -0,0 +1,37 @@
1
+ import type { Candidate } from './candidates.js';
2
+ import type { Doc } from './doc.js';
3
+ export declare const FEATURE_NAMES: readonly ["len", "digits", "upper", "lower", "isDigits", "isUpperAlpha", "isMixedCase", "kindGrouped", "kindSpaced", "kindDashedPart", "hasDash", "leadingZero", "distinctRatio", "trivialSequence", "looksYear", "colonBefore", "hashBefore", "currencyNear", "plusOrParenBefore", "digitNearby", "percentAfter", "strongBefore", "strongAfter", "weakBefore", "weakAfter", "cueBefore", "cueAfter", "negBefore", "negAfter", "strongInLine", "weakInLine", "negInLine", "strongRank", "lineLen", "aloneOnLine", "blockLen", "aloneInBlock", "fontSize", "fontRatio", "isLargestFont", "bold", "letterSpacing", "link", "heading", "mono", "hidden", "quoted", "relPosition", "fromHtml", "occurrences", "inOtherDoc", "inSubject", "subjectStrong", "subjectWeak", "subjectNeg", "docStrong", "docCue", "docLen", "candidates", "distinctCodes", "candidateIndex", "promoBefore", "promoAfter", "promoInLine", "docPromo", "subjectPromo", "vowelRatio", "longestLetterRun", "caseChanges"];
4
+ type Hits = {
5
+ start: number;
6
+ end: number;
7
+ }[];
8
+ /** Per-document data shared by all candidates of the document */
9
+ export type DocContext = {
10
+ doc: Doc;
11
+ strong: Hits;
12
+ weak: Hits;
13
+ cue: Hits;
14
+ neg: Hits;
15
+ promo: Hits;
16
+ dominantFont: number;
17
+ maxCandidateFont: number;
18
+ candidates: Candidate[];
19
+ strongRanks: Map<Candidate, number>;
20
+ /** How many candidates of the document share each code */
21
+ codeCounts: Map<string, number>;
22
+ };
23
+ /** Message data shared by the documents of one email */
24
+ export type MessageContext = {
25
+ subject: string;
26
+ otherText: string;
27
+ distinctCodes: number;
28
+ subjectStrong: boolean;
29
+ subjectWeak: boolean;
30
+ subjectNeg: boolean;
31
+ subjectPromo: boolean;
32
+ };
33
+ export declare function messageContext(subject: string, otherText: string, distinctCodes: number): MessageContext;
34
+ export declare function docContext(doc: Doc, candidates: Candidate[]): DocContext;
35
+ /** The feature vector of the candidate at `index` in ctx.candidates */
36
+ export declare function candidateFeatures(ctx: DocContext, msg: MessageContext, index: number): number[];
37
+ export {};