@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.
@@ -0,0 +1,320 @@
1
+ "use strict";
2
+ // Turns each candidate into a fixed-length vector of numbers for the scorer. FEATURE_NAMES is the
3
+ // contract with the trained model: training records the names it was fit on, and the model refuses
4
+ // to load against a different list. Add features at the end and retrain; never reorder.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.FEATURE_NAMES = void 0;
7
+ exports.messageContext = messageContext;
8
+ exports.docContext = docContext;
9
+ exports.candidateFeatures = candidateFeatures;
10
+ const doc_js_1 = require("./doc.js");
11
+ const keywords_js_1 = require("./keywords.js");
12
+ exports.FEATURE_NAMES = [
13
+ // shape
14
+ 'len',
15
+ 'digits',
16
+ 'upper',
17
+ 'lower',
18
+ 'isDigits',
19
+ 'isUpperAlpha',
20
+ 'isMixedCase',
21
+ 'kindGrouped',
22
+ 'kindSpaced',
23
+ 'kindDashedPart',
24
+ 'hasDash',
25
+ 'leadingZero',
26
+ 'distinctRatio',
27
+ 'trivialSequence',
28
+ 'looksYear',
29
+ // characters around it
30
+ 'colonBefore',
31
+ 'hashBefore',
32
+ 'currencyNear',
33
+ 'plusOrParenBefore',
34
+ 'digitNearby',
35
+ 'percentAfter',
36
+ // keywords, distances in characters, capped
37
+ 'strongBefore',
38
+ 'strongAfter',
39
+ 'weakBefore',
40
+ 'weakAfter',
41
+ 'cueBefore',
42
+ 'cueAfter',
43
+ 'negBefore',
44
+ 'negAfter',
45
+ 'strongInLine',
46
+ 'weakInLine',
47
+ 'negInLine',
48
+ 'strongRank',
49
+ // layout and style
50
+ 'lineLen',
51
+ 'aloneOnLine',
52
+ 'blockLen',
53
+ 'aloneInBlock',
54
+ 'fontSize',
55
+ 'fontRatio',
56
+ 'isLargestFont',
57
+ 'bold',
58
+ 'letterSpacing',
59
+ 'link',
60
+ 'heading',
61
+ 'mono',
62
+ 'hidden',
63
+ 'quoted',
64
+ 'relPosition',
65
+ 'fromHtml',
66
+ // message
67
+ 'occurrences',
68
+ 'inOtherDoc',
69
+ 'inSubject',
70
+ 'subjectStrong',
71
+ 'subjectWeak',
72
+ 'subjectNeg',
73
+ 'docStrong',
74
+ 'docCue',
75
+ 'docLen',
76
+ 'candidates',
77
+ 'distinctCodes',
78
+ 'candidateIndex',
79
+ // codes that prove nothing: promotions, referrals, invitations
80
+ 'promoBefore',
81
+ 'promoAfter',
82
+ 'promoInLine',
83
+ 'docPromo',
84
+ 'subjectPromo',
85
+ // letters-only codes are random (QWKZT), words in capitals are not (WEBINAR)
86
+ 'vowelRatio',
87
+ // identifiers from code and prose (loginFailedError, retry2fa) have word-like letter runs
88
+ 'longestLetterRun',
89
+ 'caseChanges'
90
+ ];
91
+ const FAR = 1000;
92
+ const CURRENCY_RE = /[€$£¥₽]|\b(?:eur|usd|gbp|sek|nok|dkk|rub)\b/i;
93
+ function messageContext(subject, otherText, distinctCodes) {
94
+ return {
95
+ subject,
96
+ otherText,
97
+ distinctCodes,
98
+ subjectStrong: (0, keywords_js_1.matches)(keywords_js_1.STRONG, subject),
99
+ subjectWeak: (0, keywords_js_1.matches)(keywords_js_1.WEAK, subject),
100
+ subjectNeg: (0, keywords_js_1.matches)(keywords_js_1.NEGATIVE, subject),
101
+ subjectPromo: (0, keywords_js_1.matches)(keywords_js_1.PROMO, subject)
102
+ };
103
+ }
104
+ // Hits come from one global regex, so they are sorted and do not overlap: starts and ends both
105
+ // increase, and every lookup below is a binary search instead of a scan of an uncapped list.
106
+ /** Index of the first hit whose `key` is at least `pos`, hits.length if none */
107
+ function firstAtLeast(hits, key, pos) {
108
+ let lo = 0;
109
+ let hi = hits.length;
110
+ while (lo < hi) {
111
+ const mid = Math.floor((lo + hi) / 2);
112
+ if (hits[mid][key] < pos) {
113
+ lo = mid + 1;
114
+ }
115
+ else {
116
+ hi = mid;
117
+ }
118
+ }
119
+ return lo;
120
+ }
121
+ /** Distance from the end of the closest hit that ends at or before pos */
122
+ function distanceBefore(hits, pos) {
123
+ const i = firstAtLeast(hits, 'end', pos + 1) - 1;
124
+ return i >= 0 ? Math.min(FAR, pos - hits[i].end) : FAR;
125
+ }
126
+ /** Distance to the start of the first hit that starts at or after pos */
127
+ function distanceAfter(hits, pos) {
128
+ const i = firstAtLeast(hits, 'start', pos);
129
+ return i < hits.length ? Math.min(FAR, hits[i].start - pos) : FAR;
130
+ }
131
+ /** Whether a whole hit lies within [from, to]; the first hit starting at from has the smallest end */
132
+ function inRange(hits, from, to) {
133
+ const i = firstAtLeast(hits, 'start', from);
134
+ return i < hits.length && hits[i].end <= to;
135
+ }
136
+ function dominantFontSize(doc) {
137
+ const chars = new Map();
138
+ for (const span of doc.spans) {
139
+ if (!span.style.hidden) {
140
+ const size = Math.round(span.style.fontSize);
141
+ chars.set(size, (chars.get(size) ?? 0) + span.end - span.start);
142
+ }
143
+ }
144
+ let best = 16;
145
+ let most = 0;
146
+ for (const [size, count] of chars) {
147
+ if (count > most) {
148
+ best = size;
149
+ most = count;
150
+ }
151
+ }
152
+ return best;
153
+ }
154
+ function rankByStrongDistance(strong, candidates) {
155
+ // Keys computed once; the sort is stable, so equal distances keep document order
156
+ const keyed = candidates.map(c => ({ c, key: Math.min(distanceBefore(strong, c.start), distanceAfter(strong, c.end)) }));
157
+ keyed.sort((a, b) => a.key - b.key);
158
+ return new Map(keyed.map((k, i) => [k.c, i]));
159
+ }
160
+ function docContext(doc, candidates) {
161
+ const strong = (0, keywords_js_1.findAll)(keywords_js_1.STRONG, doc.text);
162
+ const codeCounts = new Map();
163
+ for (const c of candidates) {
164
+ codeCounts.set(c.code, (codeCounts.get(c.code) ?? 0) + 1);
165
+ }
166
+ return {
167
+ doc,
168
+ strong,
169
+ weak: (0, keywords_js_1.findAll)(keywords_js_1.WEAK, doc.text),
170
+ cue: (0, keywords_js_1.findAll)(keywords_js_1.CUE, doc.text),
171
+ neg: (0, keywords_js_1.findAll)(keywords_js_1.NEGATIVE, doc.text),
172
+ promo: (0, keywords_js_1.findAll)(keywords_js_1.PROMO, doc.text),
173
+ dominantFont: dominantFontSize(doc),
174
+ maxCandidateFont: Math.max(0, ...candidates.map(c => (0, doc_js_1.styleAt)(doc, c.start).fontSize)),
175
+ candidates,
176
+ strongRanks: rankByStrongDistance(strong, candidates),
177
+ codeCounts
178
+ };
179
+ }
180
+ function lineOf(text, c) {
181
+ const start = text.lastIndexOf('\n', c.start - 1) + 1;
182
+ const nl = text.indexOf('\n', c.end);
183
+ const end = nl < 0 ? text.length : nl;
184
+ return { start, end, text: text.slice(start, end) };
185
+ }
186
+ function shapeFeatures(c) {
187
+ const code = c.code;
188
+ const digits = (code.match(/\d/g) ?? []).length;
189
+ const upper = (code.match(/[A-Z]/g) ?? []).length;
190
+ const lower = (code.match(/[a-z]/g) ?? []).length;
191
+ const trivial = /^(\d)\1+$/.test(code) || '0123456789'.includes(code) || '9876543210'.includes(code);
192
+ return [
193
+ code.length,
194
+ digits,
195
+ upper,
196
+ lower,
197
+ Number(digits === code.length),
198
+ Number(upper === code.length),
199
+ Number(upper > 0 && lower > 0),
200
+ Number(c.kind === 'grouped'),
201
+ Number(c.kind === 'spaced'),
202
+ Number(c.kind === 'dashed-part'),
203
+ Number(c.raw.includes('-')),
204
+ Number(code.startsWith('0')),
205
+ new Set(code).size / code.length,
206
+ Number(trivial),
207
+ Number(/^(?:19|20)\d\d$/.test(code))
208
+ ];
209
+ }
210
+ function surroundingFeatures(text, c) {
211
+ const before = text.slice(Math.max(0, c.start - 4), c.start);
212
+ const after = text.slice(c.end, c.end + 4);
213
+ return [
214
+ Number(/:\s*$/.test(before)),
215
+ Number(/#\s*$/.test(before)),
216
+ Number(CURRENCY_RE.test(before) || CURRENCY_RE.test(after)),
217
+ Number(/[+(]\s*$/.test(before)),
218
+ Number(/\d/.test(text.slice(Math.max(0, c.start - 3), c.start)) || /\d/.test(text.slice(c.end, c.end + 3))),
219
+ Number(/^\s*%/.test(after))
220
+ ];
221
+ }
222
+ function keywordFeatures(ctx, c, line) {
223
+ return [
224
+ distanceBefore(ctx.strong, c.start),
225
+ distanceAfter(ctx.strong, c.end),
226
+ distanceBefore(ctx.weak, c.start),
227
+ distanceAfter(ctx.weak, c.end),
228
+ distanceBefore(ctx.cue, c.start),
229
+ distanceAfter(ctx.cue, c.end),
230
+ distanceBefore(ctx.neg, c.start),
231
+ distanceAfter(ctx.neg, c.end),
232
+ Number(inRange(ctx.strong, line.start, line.end)),
233
+ Number(inRange(ctx.weak, line.start, line.end)),
234
+ Number(inRange(ctx.neg, line.start, line.end)),
235
+ ctx.strongRanks.get(c)
236
+ ];
237
+ }
238
+ function layoutFeatures(ctx, c, line) {
239
+ const { doc } = ctx;
240
+ const style = (0, doc_js_1.styleAt)(doc, c.start);
241
+ const block = (0, doc_js_1.blockAround)(doc, c.start, c.end);
242
+ const blockText = doc.text.slice(block.start, block.end).trim();
243
+ const lineText = line.text.trim();
244
+ return [
245
+ lineText.length,
246
+ Number(lineText === c.raw),
247
+ blockText.length,
248
+ Number(blockText === c.raw),
249
+ style.fontSize,
250
+ style.fontSize / (ctx.dominantFont || 16),
251
+ Number(style.fontSize >= ctx.maxCandidateFont),
252
+ Number(style.bold),
253
+ Number(style.letterSpacing),
254
+ Number(style.link),
255
+ Number(style.heading),
256
+ Number(style.mono),
257
+ Number(style.hidden),
258
+ Number(style.quote || c.start >= doc.quoteStart),
259
+ doc.text.length ? c.start / doc.text.length : 0,
260
+ Number(doc.source === 'html')
261
+ ];
262
+ }
263
+ function vowelRatio(code) {
264
+ const letters = code.match(/[A-Za-z]/g) ?? [];
265
+ if (!letters.length) {
266
+ return -1;
267
+ }
268
+ return letters.filter(ch => /[aeiouy]/i.test(ch)).length / letters.length;
269
+ }
270
+ function promoFeatures(ctx, msg, c, line) {
271
+ return [
272
+ distanceBefore(ctx.promo, c.start),
273
+ distanceAfter(ctx.promo, c.end),
274
+ Number(inRange(ctx.promo, line.start, line.end)),
275
+ ctx.promo.length,
276
+ Number(msg.subjectPromo)
277
+ ];
278
+ }
279
+ function messageFeatures(ctx, msg, c, index) {
280
+ return [
281
+ ctx.codeCounts.get(c.code),
282
+ Number(msg.otherText.includes(c.raw) || msg.otherText.includes(c.code)),
283
+ Number(msg.subject.includes(c.raw) || msg.subject.includes(c.code)),
284
+ Number(msg.subjectStrong),
285
+ Number(msg.subjectWeak),
286
+ Number(msg.subjectNeg),
287
+ ctx.strong.length,
288
+ Number(ctx.cue.length > 0),
289
+ Math.log1p(ctx.doc.text.length),
290
+ ctx.candidates.length,
291
+ msg.distinctCodes,
292
+ index
293
+ ];
294
+ }
295
+ function codeTextFeatures(code) {
296
+ return [
297
+ vowelRatio(code),
298
+ Math.max(0, ...(code.match(/[A-Za-z]+/g) ?? []).map(run => run.length)),
299
+ (code.match(/[a-z](?=[A-Z])|[A-Z](?=[a-z])/g) ?? []).length
300
+ ];
301
+ }
302
+ /** The feature vector of the candidate at `index` in ctx.candidates */
303
+ function candidateFeatures(ctx, msg, index) {
304
+ const c = ctx.candidates[index];
305
+ const text = ctx.doc.text;
306
+ const line = lineOf(text, c);
307
+ const row = [
308
+ ...shapeFeatures(c),
309
+ ...surroundingFeatures(text, c),
310
+ ...keywordFeatures(ctx, c, line),
311
+ ...layoutFeatures(ctx, c, line),
312
+ ...messageFeatures(ctx, msg, c, index),
313
+ ...promoFeatures(ctx, msg, c, line),
314
+ ...codeTextFeatures(c.code)
315
+ ];
316
+ if (row.length !== exports.FEATURE_NAMES.length) {
317
+ throw new Error(`feature vector has ${row.length} values, expected ${exports.FEATURE_NAMES.length}`);
318
+ }
319
+ return row;
320
+ }
@@ -0,0 +1,3 @@
1
+ import type { Doc } from './doc.js';
2
+ export declare function decodeEntities(input: string): string;
3
+ export declare function renderHtml(html: string): Doc;
@@ -0,0 +1,341 @@
1
+ "use strict";
2
+ // Renders HTML into plain text while remembering how each piece of text was styled, because the
3
+ // way a code is presented (big, bold, letter spaced, alone in its own cell) is the strongest
4
+ // signal that a number is the one-time code and not an order number.
5
+ //
6
+ // This is not a browser. It walks the tags in a single linear pass, applies the handful of inline
7
+ // styles and elements that change how text looks, and treats everything else as a container.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.decodeEntities = decodeEntities;
10
+ exports.renderHtml = renderHtml;
11
+ const doc_js_1 = require("./doc.js");
12
+ // Comments, doctype and processing instructions, tags (quoted attribute values may contain `>`),
13
+ // text. A tag never spans a `<` outside quotes, so an unclosed tag costs a scan to the next `<`
14
+ // instead of one scan to the end of the input per `<`.
15
+ const TOKEN_RE = /<!--[\s\S]*?(?:-->|$)|<![^<>]*>|<\?[^<>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"'<]|"[^"]*"|'[^']*')*)>|[^<]+|</g;
16
+ const SKIP_CONTENT = new Set(['head', 'script', 'style', 'title', 'noscript', 'template', 'svg', 'math', 'xml']);
17
+ const VOID = new Set(['br', 'img', 'hr', 'meta', 'link', 'input', 'wbr', 'col', 'area', 'base', 'source', 'track', 'embed', 'param']);
18
+ const BLOCK = new Set([
19
+ 'p',
20
+ 'div',
21
+ 'td',
22
+ 'th',
23
+ 'tr',
24
+ 'li',
25
+ 'ul',
26
+ 'ol',
27
+ 'table',
28
+ 'tbody',
29
+ 'thead',
30
+ 'tfoot',
31
+ 'h1',
32
+ 'h2',
33
+ 'h3',
34
+ 'h4',
35
+ 'h5',
36
+ 'h6',
37
+ 'section',
38
+ 'article',
39
+ 'header',
40
+ 'footer',
41
+ 'main',
42
+ 'nav',
43
+ 'aside',
44
+ 'blockquote',
45
+ 'center',
46
+ 'pre',
47
+ 'dd',
48
+ 'dt',
49
+ 'dl',
50
+ 'address',
51
+ 'form',
52
+ 'fieldset',
53
+ 'figure',
54
+ 'figcaption',
55
+ 'hr',
56
+ 'caption'
57
+ ]);
58
+ const MONO_TAGS = new Set(['code', 'pre', 'tt', 'kbd', 'samp']);
59
+ const HEADING_SIZE = { h1: 32, h2: 24, h3: 19, h4: 16, h5: 13, h6: 11 };
60
+ // <font size="1"> to <font size="7"> in pixels, as browsers map them
61
+ const FONT_TAG_SIZE = [0, 10, 13, 16, 18, 24, 32, 48];
62
+ const NAMED_ENTITIES = {
63
+ amp: '&',
64
+ lt: '<',
65
+ gt: '>',
66
+ quot: '"',
67
+ apos: "'",
68
+ nbsp: ' ',
69
+ ensp: ' ',
70
+ emsp: ' ',
71
+ thinsp: ' ',
72
+ zwnj: '',
73
+ zwj: '',
74
+ shy: '',
75
+ copy: '©',
76
+ reg: '®',
77
+ trade: '™',
78
+ hellip: '…',
79
+ mdash: '-',
80
+ ndash: '-',
81
+ laquo: '«',
82
+ raquo: '»',
83
+ lsquo: "'",
84
+ rsquo: "'",
85
+ ldquo: '"',
86
+ rdquo: '"',
87
+ bdquo: '"',
88
+ bull: '•',
89
+ middot: '·',
90
+ euro: '€',
91
+ pound: '£',
92
+ yen: '¥',
93
+ cent: '¢',
94
+ sect: '§',
95
+ deg: '°',
96
+ times: '×',
97
+ auml: 'ä',
98
+ ouml: 'ö',
99
+ uuml: 'ü',
100
+ otilde: 'õ',
101
+ Auml: 'Ä',
102
+ Ouml: 'Ö',
103
+ Uuml: 'Ü',
104
+ Otilde: 'Õ',
105
+ scaron: 'š',
106
+ Scaron: 'Š',
107
+ zcaron: 'ž',
108
+ Zcaron: 'Ž',
109
+ szlig: 'ß',
110
+ aacute: 'á',
111
+ eacute: 'é',
112
+ iacute: 'í',
113
+ oacute: 'ó',
114
+ uacute: 'ú',
115
+ agrave: 'à',
116
+ egrave: 'è',
117
+ ecirc: 'ê',
118
+ ccedil: 'ç',
119
+ ntilde: 'ñ',
120
+ aring: 'å',
121
+ Aring: 'Å',
122
+ oslash: 'ø',
123
+ Oslash: 'Ø',
124
+ aelig: 'æ',
125
+ AElig: 'Æ'
126
+ };
127
+ const ENTITY_RE = /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));?/g;
128
+ function decodeEntities(input) {
129
+ if (!input.includes('&')) {
130
+ return input;
131
+ }
132
+ return input.replace(ENTITY_RE, (match, dec, hex, name) => {
133
+ if (name !== undefined) {
134
+ return NAMED_ENTITIES[name] ?? match;
135
+ }
136
+ const code = dec !== undefined ? parseInt(dec, 10) : parseInt(hex, 16);
137
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
138
+ return '\ufffd';
139
+ }
140
+ return String.fromCodePoint(code);
141
+ });
142
+ }
143
+ // The attributes elementStyle reads, compiled once instead of on every tag
144
+ const ATTR_RE = Object.fromEntries(['href', 'size', 'hidden', 'style', 'class', 'type'].map(name => [name, new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i')]));
145
+ function attr(attrs, name) {
146
+ const m = ATTR_RE[name].exec(attrs);
147
+ return m ? decodeEntities(m[1] ?? m[2] ?? m[3] ?? '') : undefined;
148
+ }
149
+ function cssLength(value, parentSize) {
150
+ const m = /^\s*(-?[\d.]+)\s*(px|pt|em|rem|%)?/i.exec(value);
151
+ if (!m) {
152
+ return /^\s*0\b/.test(value) ? 0 : undefined;
153
+ }
154
+ const n = parseFloat(m[1]);
155
+ if (!Number.isFinite(n)) {
156
+ return undefined;
157
+ }
158
+ switch ((m[2] || 'px').toLowerCase()) {
159
+ case 'pt':
160
+ return n * (4 / 3);
161
+ case 'em':
162
+ return n * parentSize;
163
+ case 'rem':
164
+ return n * doc_js_1.DEFAULT_FONT_SIZE;
165
+ case '%':
166
+ return (n / 100) * parentSize;
167
+ default:
168
+ return n;
169
+ }
170
+ }
171
+ const KEYWORD_SIZES = {
172
+ 'xx-small': 9,
173
+ 'x-small': 10,
174
+ small: 13,
175
+ medium: 16,
176
+ large: 18,
177
+ 'x-large': 24,
178
+ 'xx-large': 32,
179
+ 'xxx-large': 48
180
+ };
181
+ // Applies the inline CSS declarations that change how a code would look
182
+ function applyCss(style, css, parentSize) {
183
+ for (const decl of css.split(';')) {
184
+ const colon = decl.indexOf(':');
185
+ if (colon < 0) {
186
+ continue;
187
+ }
188
+ const prop = decl.slice(0, colon).trim().toLowerCase();
189
+ const value = decl
190
+ .slice(colon + 1)
191
+ .replace(/!important/i, '')
192
+ .trim()
193
+ .toLowerCase();
194
+ applyDeclaration(style, prop, value, parentSize);
195
+ }
196
+ }
197
+ function applyDeclaration(style, prop, value, parentSize) {
198
+ switch (prop) {
199
+ case 'font-size': {
200
+ const size = KEYWORD_SIZES[value] ?? cssLength(value, parentSize);
201
+ if (size !== undefined) {
202
+ style.fontSize = size;
203
+ style.hidden ||= size <= 1;
204
+ }
205
+ break;
206
+ }
207
+ case 'font-weight':
208
+ style.bold = value === 'bold' || value === 'bolder' || parseInt(value, 10) >= 600;
209
+ break;
210
+ case 'letter-spacing': {
211
+ const spacing = cssLength(value, parentSize);
212
+ style.letterSpacing = spacing !== undefined && spacing >= 1;
213
+ break;
214
+ }
215
+ case 'font-family':
216
+ style.mono ||= /mono|courier|consolas|menlo/.test(value);
217
+ break;
218
+ case 'display':
219
+ style.hidden ||= value === 'none';
220
+ break;
221
+ case 'visibility':
222
+ style.hidden ||= value === 'hidden';
223
+ break;
224
+ case 'opacity':
225
+ style.hidden ||= parseFloat(value) === 0;
226
+ break;
227
+ case 'max-height':
228
+ case 'height':
229
+ style.hidden ||= /^0(?:px)?$/.test(value);
230
+ break;
231
+ default:
232
+ break;
233
+ }
234
+ }
235
+ function elementStyle(tag, attrs, parent) {
236
+ const style = { ...parent };
237
+ if (tag === 'b' || tag === 'strong') {
238
+ style.bold = true;
239
+ }
240
+ else if (tag in HEADING_SIZE) {
241
+ style.heading = true;
242
+ style.bold = true;
243
+ style.fontSize = HEADING_SIZE[tag];
244
+ }
245
+ else if (tag === 'a' && attr(attrs, 'href')) {
246
+ style.link = true;
247
+ }
248
+ else if (tag === 'blockquote') {
249
+ style.quote = true;
250
+ }
251
+ else if (MONO_TAGS.has(tag)) {
252
+ style.mono = true;
253
+ }
254
+ else if (tag === 'font') {
255
+ const size = parseInt(attr(attrs, 'size') ?? '', 10);
256
+ if (size >= 1 && size <= 7) {
257
+ style.fontSize = FONT_TAG_SIZE[size];
258
+ }
259
+ }
260
+ if (attr(attrs, 'hidden') !== undefined) {
261
+ style.hidden = true;
262
+ }
263
+ const css = attr(attrs, 'style');
264
+ if (css) {
265
+ applyCss(style, css, parent.fontSize);
266
+ }
267
+ // Quoted reply history in the common clients
268
+ const cls = attr(attrs, 'class') ?? '';
269
+ if (/\bgmail_quote\b|\byahoo_quoted\b|\bmoz-cite-prefix\b/i.test(cls) || attr(attrs, 'type')?.toLowerCase() === 'cite') {
270
+ style.quote = true;
271
+ }
272
+ return style;
273
+ }
274
+ function renderHtml(html) {
275
+ const doc = new doc_js_1.DocBuilder('html');
276
+ const stack = [];
277
+ let skipUntil = null;
278
+ const current = () => (stack.length ? stack[stack.length - 1].style : doc_js_1.DEFAULT_STYLE);
279
+ TOKEN_RE.lastIndex = 0;
280
+ for (let m = TOKEN_RE.exec(html); m; m = TOKEN_RE.exec(html)) {
281
+ const token = m[0];
282
+ const tag = m[2]?.toLowerCase();
283
+ if (skipUntil) {
284
+ if (tag === skipUntil && m[1] === '/') {
285
+ skipUntil = null;
286
+ }
287
+ continue;
288
+ }
289
+ if (tag === undefined) {
290
+ if (token[0] !== '<' || token === '<') {
291
+ doc.text(decodeEntities(token), current());
292
+ }
293
+ continue;
294
+ }
295
+ if (m[1] === '/') {
296
+ closeTag(doc, stack, tag);
297
+ continue;
298
+ }
299
+ const attrs = m[3] ?? '';
300
+ const selfClosing = /\/\s*$/.test(attrs);
301
+ if (SKIP_CONTENT.has(tag) && !selfClosing) {
302
+ skipUntil = tag;
303
+ continue;
304
+ }
305
+ openTag(doc, stack, tag, attrs, selfClosing, current());
306
+ }
307
+ return doc.finish();
308
+ }
309
+ function openTag(doc, stack, tag, attrs, selfClosing, parent) {
310
+ if (tag === 'br') {
311
+ doc.newline();
312
+ return;
313
+ }
314
+ const block = BLOCK.has(tag);
315
+ if (VOID.has(tag) || selfClosing) {
316
+ if (block) {
317
+ doc.newline();
318
+ }
319
+ return;
320
+ }
321
+ if (block) {
322
+ doc.openBlock();
323
+ }
324
+ stack.push({ tag, style: elementStyle(tag, attrs, parent), block });
325
+ }
326
+ function closeTag(doc, stack, tag) {
327
+ // Close up to the matching element; a stray close tag with no match is ignored
328
+ let i = stack.length - 1;
329
+ while (i >= 0 && stack[i].tag !== tag) {
330
+ i--;
331
+ }
332
+ if (i < 0) {
333
+ return;
334
+ }
335
+ while (stack.length > i) {
336
+ const open = stack.pop();
337
+ if (open.block) {
338
+ doc.closeBlock();
339
+ }
340
+ }
341
+ }
@@ -0,0 +1,36 @@
1
+ import type { MessageInput } from './analyze.js';
2
+ import type { Model } from './model.js';
3
+ export type { MessageInput } from './analyze.js';
4
+ export type { Model } from './model.js';
5
+ export type OtpCandidate = {
6
+ /** The code as it should be typed */
7
+ code: string;
8
+ /** The code as printed in the message */
9
+ raw: string;
10
+ /** Probability, 0 to 1, that this is the one-time code */
11
+ score: number;
12
+ /** Which body the best occurrence was found in */
13
+ source: 'html' | 'text';
14
+ };
15
+ export type DetectOptions = {
16
+ /** Minimum score for a code to be returned. Defaults to the threshold the model was tuned with */
17
+ threshold?: number;
18
+ /** Use a different trained model */
19
+ model?: Model;
20
+ };
21
+ /**
22
+ * Every code-like token in the message with its score, best first. Meant for debugging and for
23
+ * callers that want to apply their own rules.
24
+ */
25
+ export declare function scoreCandidates(input: MessageInput, options?: DetectOptions): OtpCandidate[];
26
+ /**
27
+ * Finds the one-time code in a parsed email, or returns null. The message is not trusted: check
28
+ * that it is authenticated (DKIM aligned with the sender) before offering the code to a user.
29
+ * Codes from an `OTP-Token` header (postal-mime `message.otp`) should be preferred when present.
30
+ */
31
+ export declare function detectOtp(input: MessageInput, options?: DetectOptions): OtpCandidate | null;
32
+ declare const _default: {
33
+ detectOtp: typeof detectOtp;
34
+ scoreCandidates: typeof scoreCandidates;
35
+ };
36
+ export default _default;