@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,337 @@
1
+ // Renders HTML into plain text while remembering how each piece of text was styled, because the
2
+ // way a code is presented (big, bold, letter spaced, alone in its own cell) is the strongest
3
+ // signal that a number is the one-time code and not an order number.
4
+ //
5
+ // This is not a browser. It walks the tags in a single linear pass, applies the handful of inline
6
+ // styles and elements that change how text looks, and treats everything else as a container.
7
+ import { DEFAULT_FONT_SIZE, DEFAULT_STYLE, DocBuilder } from './doc.js';
8
+ // Comments, doctype and processing instructions, tags (quoted attribute values may contain `>`),
9
+ // text. A tag never spans a `<` outside quotes, so an unclosed tag costs a scan to the next `<`
10
+ // instead of one scan to the end of the input per `<`.
11
+ const TOKEN_RE = /<!--[\s\S]*?(?:-->|$)|<![^<>]*>|<\?[^<>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"'<]|"[^"]*"|'[^']*')*)>|[^<]+|</g;
12
+ const SKIP_CONTENT = new Set(['head', 'script', 'style', 'title', 'noscript', 'template', 'svg', 'math', 'xml']);
13
+ const VOID = new Set(['br', 'img', 'hr', 'meta', 'link', 'input', 'wbr', 'col', 'area', 'base', 'source', 'track', 'embed', 'param']);
14
+ const BLOCK = new Set([
15
+ 'p',
16
+ 'div',
17
+ 'td',
18
+ 'th',
19
+ 'tr',
20
+ 'li',
21
+ 'ul',
22
+ 'ol',
23
+ 'table',
24
+ 'tbody',
25
+ 'thead',
26
+ 'tfoot',
27
+ 'h1',
28
+ 'h2',
29
+ 'h3',
30
+ 'h4',
31
+ 'h5',
32
+ 'h6',
33
+ 'section',
34
+ 'article',
35
+ 'header',
36
+ 'footer',
37
+ 'main',
38
+ 'nav',
39
+ 'aside',
40
+ 'blockquote',
41
+ 'center',
42
+ 'pre',
43
+ 'dd',
44
+ 'dt',
45
+ 'dl',
46
+ 'address',
47
+ 'form',
48
+ 'fieldset',
49
+ 'figure',
50
+ 'figcaption',
51
+ 'hr',
52
+ 'caption'
53
+ ]);
54
+ const MONO_TAGS = new Set(['code', 'pre', 'tt', 'kbd', 'samp']);
55
+ const HEADING_SIZE = { h1: 32, h2: 24, h3: 19, h4: 16, h5: 13, h6: 11 };
56
+ // <font size="1"> to <font size="7"> in pixels, as browsers map them
57
+ const FONT_TAG_SIZE = [0, 10, 13, 16, 18, 24, 32, 48];
58
+ const NAMED_ENTITIES = {
59
+ amp: '&',
60
+ lt: '<',
61
+ gt: '>',
62
+ quot: '"',
63
+ apos: "'",
64
+ nbsp: ' ',
65
+ ensp: ' ',
66
+ emsp: ' ',
67
+ thinsp: ' ',
68
+ zwnj: '',
69
+ zwj: '',
70
+ shy: '',
71
+ copy: '©',
72
+ reg: '®',
73
+ trade: '™',
74
+ hellip: '…',
75
+ mdash: '-',
76
+ ndash: '-',
77
+ laquo: '«',
78
+ raquo: '»',
79
+ lsquo: "'",
80
+ rsquo: "'",
81
+ ldquo: '"',
82
+ rdquo: '"',
83
+ bdquo: '"',
84
+ bull: '•',
85
+ middot: '·',
86
+ euro: '€',
87
+ pound: '£',
88
+ yen: '¥',
89
+ cent: '¢',
90
+ sect: '§',
91
+ deg: '°',
92
+ times: '×',
93
+ auml: 'ä',
94
+ ouml: 'ö',
95
+ uuml: 'ü',
96
+ otilde: 'õ',
97
+ Auml: 'Ä',
98
+ Ouml: 'Ö',
99
+ Uuml: 'Ü',
100
+ Otilde: 'Õ',
101
+ scaron: 'š',
102
+ Scaron: 'Š',
103
+ zcaron: 'ž',
104
+ Zcaron: 'Ž',
105
+ szlig: 'ß',
106
+ aacute: 'á',
107
+ eacute: 'é',
108
+ iacute: 'í',
109
+ oacute: 'ó',
110
+ uacute: 'ú',
111
+ agrave: 'à',
112
+ egrave: 'è',
113
+ ecirc: 'ê',
114
+ ccedil: 'ç',
115
+ ntilde: 'ñ',
116
+ aring: 'å',
117
+ Aring: 'Å',
118
+ oslash: 'ø',
119
+ Oslash: 'Ø',
120
+ aelig: 'æ',
121
+ AElig: 'Æ'
122
+ };
123
+ const ENTITY_RE = /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{1,31}));?/g;
124
+ export function decodeEntities(input) {
125
+ if (!input.includes('&')) {
126
+ return input;
127
+ }
128
+ return input.replace(ENTITY_RE, (match, dec, hex, name) => {
129
+ if (name !== undefined) {
130
+ return NAMED_ENTITIES[name] ?? match;
131
+ }
132
+ const code = dec !== undefined ? parseInt(dec, 10) : parseInt(hex, 16);
133
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
134
+ return '\ufffd';
135
+ }
136
+ return String.fromCodePoint(code);
137
+ });
138
+ }
139
+ // The attributes elementStyle reads, compiled once instead of on every tag
140
+ const ATTR_RE = Object.fromEntries(['href', 'size', 'hidden', 'style', 'class', 'type'].map(name => [name, new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i')]));
141
+ function attr(attrs, name) {
142
+ const m = ATTR_RE[name].exec(attrs);
143
+ return m ? decodeEntities(m[1] ?? m[2] ?? m[3] ?? '') : undefined;
144
+ }
145
+ function cssLength(value, parentSize) {
146
+ const m = /^\s*(-?[\d.]+)\s*(px|pt|em|rem|%)?/i.exec(value);
147
+ if (!m) {
148
+ return /^\s*0\b/.test(value) ? 0 : undefined;
149
+ }
150
+ const n = parseFloat(m[1]);
151
+ if (!Number.isFinite(n)) {
152
+ return undefined;
153
+ }
154
+ switch ((m[2] || 'px').toLowerCase()) {
155
+ case 'pt':
156
+ return n * (4 / 3);
157
+ case 'em':
158
+ return n * parentSize;
159
+ case 'rem':
160
+ return n * DEFAULT_FONT_SIZE;
161
+ case '%':
162
+ return (n / 100) * parentSize;
163
+ default:
164
+ return n;
165
+ }
166
+ }
167
+ const KEYWORD_SIZES = {
168
+ 'xx-small': 9,
169
+ 'x-small': 10,
170
+ small: 13,
171
+ medium: 16,
172
+ large: 18,
173
+ 'x-large': 24,
174
+ 'xx-large': 32,
175
+ 'xxx-large': 48
176
+ };
177
+ // Applies the inline CSS declarations that change how a code would look
178
+ function applyCss(style, css, parentSize) {
179
+ for (const decl of css.split(';')) {
180
+ const colon = decl.indexOf(':');
181
+ if (colon < 0) {
182
+ continue;
183
+ }
184
+ const prop = decl.slice(0, colon).trim().toLowerCase();
185
+ const value = decl
186
+ .slice(colon + 1)
187
+ .replace(/!important/i, '')
188
+ .trim()
189
+ .toLowerCase();
190
+ applyDeclaration(style, prop, value, parentSize);
191
+ }
192
+ }
193
+ function applyDeclaration(style, prop, value, parentSize) {
194
+ switch (prop) {
195
+ case 'font-size': {
196
+ const size = KEYWORD_SIZES[value] ?? cssLength(value, parentSize);
197
+ if (size !== undefined) {
198
+ style.fontSize = size;
199
+ style.hidden ||= size <= 1;
200
+ }
201
+ break;
202
+ }
203
+ case 'font-weight':
204
+ style.bold = value === 'bold' || value === 'bolder' || parseInt(value, 10) >= 600;
205
+ break;
206
+ case 'letter-spacing': {
207
+ const spacing = cssLength(value, parentSize);
208
+ style.letterSpacing = spacing !== undefined && spacing >= 1;
209
+ break;
210
+ }
211
+ case 'font-family':
212
+ style.mono ||= /mono|courier|consolas|menlo/.test(value);
213
+ break;
214
+ case 'display':
215
+ style.hidden ||= value === 'none';
216
+ break;
217
+ case 'visibility':
218
+ style.hidden ||= value === 'hidden';
219
+ break;
220
+ case 'opacity':
221
+ style.hidden ||= parseFloat(value) === 0;
222
+ break;
223
+ case 'max-height':
224
+ case 'height':
225
+ style.hidden ||= /^0(?:px)?$/.test(value);
226
+ break;
227
+ default:
228
+ break;
229
+ }
230
+ }
231
+ function elementStyle(tag, attrs, parent) {
232
+ const style = { ...parent };
233
+ if (tag === 'b' || tag === 'strong') {
234
+ style.bold = true;
235
+ }
236
+ else if (tag in HEADING_SIZE) {
237
+ style.heading = true;
238
+ style.bold = true;
239
+ style.fontSize = HEADING_SIZE[tag];
240
+ }
241
+ else if (tag === 'a' && attr(attrs, 'href')) {
242
+ style.link = true;
243
+ }
244
+ else if (tag === 'blockquote') {
245
+ style.quote = true;
246
+ }
247
+ else if (MONO_TAGS.has(tag)) {
248
+ style.mono = true;
249
+ }
250
+ else if (tag === 'font') {
251
+ const size = parseInt(attr(attrs, 'size') ?? '', 10);
252
+ if (size >= 1 && size <= 7) {
253
+ style.fontSize = FONT_TAG_SIZE[size];
254
+ }
255
+ }
256
+ if (attr(attrs, 'hidden') !== undefined) {
257
+ style.hidden = true;
258
+ }
259
+ const css = attr(attrs, 'style');
260
+ if (css) {
261
+ applyCss(style, css, parent.fontSize);
262
+ }
263
+ // Quoted reply history in the common clients
264
+ const cls = attr(attrs, 'class') ?? '';
265
+ if (/\bgmail_quote\b|\byahoo_quoted\b|\bmoz-cite-prefix\b/i.test(cls) || attr(attrs, 'type')?.toLowerCase() === 'cite') {
266
+ style.quote = true;
267
+ }
268
+ return style;
269
+ }
270
+ export function renderHtml(html) {
271
+ const doc = new DocBuilder('html');
272
+ const stack = [];
273
+ let skipUntil = null;
274
+ const current = () => (stack.length ? stack[stack.length - 1].style : DEFAULT_STYLE);
275
+ TOKEN_RE.lastIndex = 0;
276
+ for (let m = TOKEN_RE.exec(html); m; m = TOKEN_RE.exec(html)) {
277
+ const token = m[0];
278
+ const tag = m[2]?.toLowerCase();
279
+ if (skipUntil) {
280
+ if (tag === skipUntil && m[1] === '/') {
281
+ skipUntil = null;
282
+ }
283
+ continue;
284
+ }
285
+ if (tag === undefined) {
286
+ if (token[0] !== '<' || token === '<') {
287
+ doc.text(decodeEntities(token), current());
288
+ }
289
+ continue;
290
+ }
291
+ if (m[1] === '/') {
292
+ closeTag(doc, stack, tag);
293
+ continue;
294
+ }
295
+ const attrs = m[3] ?? '';
296
+ const selfClosing = /\/\s*$/.test(attrs);
297
+ if (SKIP_CONTENT.has(tag) && !selfClosing) {
298
+ skipUntil = tag;
299
+ continue;
300
+ }
301
+ openTag(doc, stack, tag, attrs, selfClosing, current());
302
+ }
303
+ return doc.finish();
304
+ }
305
+ function openTag(doc, stack, tag, attrs, selfClosing, parent) {
306
+ if (tag === 'br') {
307
+ doc.newline();
308
+ return;
309
+ }
310
+ const block = BLOCK.has(tag);
311
+ if (VOID.has(tag) || selfClosing) {
312
+ if (block) {
313
+ doc.newline();
314
+ }
315
+ return;
316
+ }
317
+ if (block) {
318
+ doc.openBlock();
319
+ }
320
+ stack.push({ tag, style: elementStyle(tag, attrs, parent), block });
321
+ }
322
+ function closeTag(doc, stack, tag) {
323
+ // Close up to the matching element; a stray close tag with no match is ignored
324
+ let i = stack.length - 1;
325
+ while (i >= 0 && stack[i].tag !== tag) {
326
+ i--;
327
+ }
328
+ if (i < 0) {
329
+ return;
330
+ }
331
+ while (stack.length > i) {
332
+ const open = stack.pop();
333
+ if (open.block) {
334
+ doc.closeBlock();
335
+ }
336
+ }
337
+ }
@@ -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;
@@ -0,0 +1,40 @@
1
+ import { analyze } from './analyze.js';
2
+ import { checkModel, predict } from './model.js';
3
+ import { MODEL } from './model-data.js';
4
+ // Models already checked against FEATURE_NAMES
5
+ const checked = new WeakSet();
6
+ function scored(input, model) {
7
+ if (!checked.has(model)) {
8
+ checkModel(model);
9
+ checked.add(model);
10
+ }
11
+ // The same code printed several times counts once, with its best score
12
+ const best = new Map();
13
+ for (const occ of analyze(input)) {
14
+ const score = predict(model, occ.features);
15
+ const prev = best.get(occ.candidate.code);
16
+ if (!prev || score > prev.score) {
17
+ best.set(occ.candidate.code, { code: occ.candidate.code, raw: occ.candidate.raw, score, source: occ.source });
18
+ }
19
+ }
20
+ return [...best.values()].sort((a, b) => b.score - a.score);
21
+ }
22
+ /**
23
+ * Every code-like token in the message with its score, best first. Meant for debugging and for
24
+ * callers that want to apply their own rules.
25
+ */
26
+ export function scoreCandidates(input, options = {}) {
27
+ return scored(input, options.model ?? MODEL);
28
+ }
29
+ /**
30
+ * Finds the one-time code in a parsed email, or returns null. The message is not trusted: check
31
+ * that it is authenticated (DKIM aligned with the sender) before offering the code to a user.
32
+ * Codes from an `OTP-Token` header (postal-mime `message.otp`) should be preferred when present.
33
+ */
34
+ export function detectOtp(input, options = {}) {
35
+ const model = options.model ?? MODEL;
36
+ const threshold = options.threshold ?? model.threshold;
37
+ const top = scored(input, model)[0];
38
+ return top && top.score >= threshold ? top : null;
39
+ }
40
+ export default { detectOtp, scoreCandidates };
@@ -0,0 +1,16 @@
1
+ /** Phrases that name a one-time code */
2
+ export declare const STRONG: RegExp;
3
+ /** Generic words for a code or secret, weaker evidence on their own */
4
+ export declare const WEAK: RegExp;
5
+ /** How a one-time code is introduced or limited: instructions, expiry, warnings */
6
+ export declare const CUE: RegExp;
7
+ /** Words that introduce the other kinds of numbers an email is full of */
8
+ export declare const NEGATIVE: RegExp;
9
+ /** Codes that are handed out but prove nothing: promotions, referrals, invitations, gifts */
10
+ export declare const PROMO: RegExp;
11
+ /** Start and end offsets of every match, sorted by start */
12
+ export declare function findAll(re: RegExp, text: string): {
13
+ start: number;
14
+ end: number;
15
+ }[];
16
+ export declare function matches(re: RegExp, text: string): boolean;