@mikkelscheike/email-provider-links 2.5.0 → 2.6.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/README.md +63 -3
- package/dist/api.d.ts +2 -1
- package/dist/concurrent-dns.d.ts +2 -0
- package/dist/concurrent-dns.js +31 -4
- package/dist/{security/hash-verifier.js → hash-verifier.js} +3 -3
- package/dist/idn.d.ts +46 -0
- package/dist/idn.js +248 -0
- package/dist/{security/secure-loader.d.ts → secure-loader.d.ts} +5 -3
- package/dist/{security/secure-loader.js → secure-loader.js} +4 -5
- package/dist/{security/url-validator.js → url-validator.js} +2 -1
- package/package.json +7 -7
- /package/dist/{security/hash-verifier.d.ts → hash-verifier.d.ts} +0 -0
- /package/dist/{security/url-validator.d.ts → url-validator.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -6,9 +6,11 @@ A TypeScript library providing direct links to **93 email providers** (178 domai
|
|
|
6
6
|
|
|
7
7
|
## ✨ Features
|
|
8
8
|
|
|
9
|
-
- 🚀 **Fast & Lightweight**: Zero dependencies,
|
|
9
|
+
- 🚀 **Fast & Lightweight**: Zero dependencies, ultra-low memory (~0.39MB initial, ~0.02MB per 1000 ops)
|
|
10
10
|
- 📧 **93 Email Providers**: Gmail, Outlook, Yahoo, ProtonMail, iCloud, and many more
|
|
11
11
|
- 🌐 **178 Domains Supported**: Comprehensive international coverage
|
|
12
|
+
- 🌍 **IDN Support**: Full internationalized domain name (punycode) support with RFC compliance
|
|
13
|
+
- ✅ **Email Validation**: International email validation following RFC 5321, 5322, and 6530 standards
|
|
12
14
|
- 🏢 **Business Domain Detection**: DNS-based detection for custom domains (Google Workspace, Microsoft 365, etc.)
|
|
13
15
|
- 🔒 **Enterprise Security**: Multi-layer protection against malicious URLs and supply chain attacks
|
|
14
16
|
- 🛡️ **URL Validation**: HTTPS-only enforcement with domain allowlisting
|
|
@@ -18,10 +20,11 @@ A TypeScript library providing direct links to **93 email providers** (178 domai
|
|
|
18
20
|
- 🚦 **Rate Limiting**: Built-in DNS query rate limiting to prevent abuse
|
|
19
21
|
- 🔄 **Email Alias Detection**: Normalize Gmail dots, plus addressing, and provider-specific aliases
|
|
20
22
|
- 🛡️ **Fraud Prevention**: Detect duplicate accounts through email alias manipulation
|
|
21
|
-
- 🧪 **Thoroughly Tested**:
|
|
23
|
+
- 🧪 **Thoroughly Tested**: 370 tests with 92.89% code coverage
|
|
22
24
|
|
|
23
25
|
## Installation
|
|
24
26
|
|
|
27
|
+
Using npm:
|
|
25
28
|
```bash
|
|
26
29
|
npm install @mikkelscheike/email-provider-links
|
|
27
30
|
```
|
|
@@ -152,6 +155,39 @@ console.log('Max requests:', Config.MAX_DNS_REQUESTS_PER_MINUTE); // 10
|
|
|
152
155
|
console.log('Default timeout:', Config.DEFAULT_DNS_TIMEOUT); // 5000ms
|
|
153
156
|
```
|
|
154
157
|
|
|
158
|
+
## Email Validation
|
|
159
|
+
|
|
160
|
+
The library includes comprehensive email validation following international standards:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { validateInternationalEmail } from '@mikkelscheike/email-provider-links';
|
|
164
|
+
|
|
165
|
+
// Validate any email address
|
|
166
|
+
const result = validateInternationalEmail('user@example.com');
|
|
167
|
+
|
|
168
|
+
// Returns undefined for valid emails
|
|
169
|
+
console.log(result); // undefined
|
|
170
|
+
|
|
171
|
+
// Returns detailed error information for invalid emails
|
|
172
|
+
const invalid = validateInternationalEmail('user@münchen.com');
|
|
173
|
+
if (invalid) {
|
|
174
|
+
console.log(invalid.code); // IDN_VALIDATION_ERROR
|
|
175
|
+
console.log(invalid.message); // Human-readable error message
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Validation Features
|
|
180
|
+
|
|
181
|
+
- ✅ **RFC Compliance**: Follows RFC 5321, 5322, and 6530 standards
|
|
182
|
+
- 🌍 **International Support**: Full IDN (Punycode) validation
|
|
183
|
+
- 📝 **Detailed Errors**: Clear, translatable error messages
|
|
184
|
+
- 🔍 **Comprehensive Checks**:
|
|
185
|
+
- Local part validation (username)
|
|
186
|
+
- Domain format validation
|
|
187
|
+
- IDN encoding validation
|
|
188
|
+
- Length limits (local part, domain, total)
|
|
189
|
+
- TLD validation
|
|
190
|
+
|
|
155
191
|
## Advanced Usage
|
|
156
192
|
|
|
157
193
|
<details>
|
|
@@ -254,6 +290,12 @@ interface EmailProviderResult {
|
|
|
254
290
|
loginUrl: string | null;
|
|
255
291
|
detectionMethod?: 'domain_match' | 'mx_record' | 'txt_record' | 'proxy_detected';
|
|
256
292
|
proxyService?: string;
|
|
293
|
+
error?: {
|
|
294
|
+
type: 'INVALID_EMAIL' | 'DNS_TIMEOUT' | 'RATE_LIMITED' | 'UNKNOWN_DOMAIN' |
|
|
295
|
+
'NETWORK_ERROR' | 'IDN_VALIDATION_ERROR';
|
|
296
|
+
message: string;
|
|
297
|
+
idnError?: string; // Specific IDN validation error message
|
|
298
|
+
};
|
|
257
299
|
}
|
|
258
300
|
|
|
259
301
|
interface ConfigConstants {
|
|
@@ -310,11 +352,29 @@ if (result.securityReport.securityLevel === 'CRITICAL') {
|
|
|
310
352
|
}
|
|
311
353
|
```
|
|
312
354
|
|
|
355
|
+
## Performance Benchmarks
|
|
356
|
+
|
|
357
|
+
This package is designed to be extremely memory efficient and fast. We continuously monitor performance metrics through automated benchmarks that run on every PR and release.
|
|
358
|
+
|
|
359
|
+
Latest benchmark results show:
|
|
360
|
+
- Provider loading: ~0.39MB heap usage, <0.5ms
|
|
361
|
+
- Email lookups: ~0.02MB heap usage per 100 operations
|
|
362
|
+
- Concurrent DNS: ~0.03MB heap usage, ~110ms for 10 lookups
|
|
363
|
+
- Large scale (1000 ops): ~0.02MB heap usage, <3ms total
|
|
364
|
+
- Cache effectiveness: ~0.01MB impact on subsequent loads
|
|
365
|
+
|
|
366
|
+
To run benchmarks locally:
|
|
367
|
+
```bash
|
|
368
|
+
npm run benchmark
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Benchmarks are automatically run in CI to catch any performance regressions.
|
|
372
|
+
|
|
313
373
|
## Contributing
|
|
314
374
|
|
|
315
375
|
We welcome contributions! See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for guidelines on adding new email providers.
|
|
316
376
|
|
|
317
|
-
**Quality Assurance**: This project maintains high standards with
|
|
377
|
+
**Quality Assurance**: This project maintains high standards with 370 comprehensive tests achieving 92.89% code coverage.
|
|
318
378
|
**Security Note**: All new providers undergo security validation and must pass our allowlist verification.
|
|
319
379
|
|
|
320
380
|
## Security
|
package/dist/api.d.ts
CHANGED
|
@@ -29,9 +29,10 @@ export interface EmailProviderResult {
|
|
|
29
29
|
proxyService?: string;
|
|
30
30
|
/** Error information if detection failed */
|
|
31
31
|
error?: {
|
|
32
|
-
type: 'INVALID_EMAIL' | 'DNS_TIMEOUT' | 'RATE_LIMITED' | 'UNKNOWN_DOMAIN' | 'NETWORK_ERROR';
|
|
32
|
+
type: 'INVALID_EMAIL' | 'DNS_TIMEOUT' | 'RATE_LIMITED' | 'UNKNOWN_DOMAIN' | 'NETWORK_ERROR' | 'IDN_VALIDATION_ERROR';
|
|
33
33
|
message: string;
|
|
34
34
|
retryAfter?: number;
|
|
35
|
+
idnError?: string;
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
/**
|
package/dist/concurrent-dns.d.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface ConcurrentDNSResult {
|
|
|
68
68
|
* Concurrent DNS Detection Engine
|
|
69
69
|
*/
|
|
70
70
|
export declare class ConcurrentDNSDetector {
|
|
71
|
+
private activeQueries;
|
|
72
|
+
cleanup(): Promise<void>;
|
|
71
73
|
private config;
|
|
72
74
|
private providers;
|
|
73
75
|
constructor(providers: EmailProvider[], config?: Partial<ConcurrentDNSConfig>);
|
package/dist/concurrent-dns.js
CHANGED
|
@@ -28,7 +28,19 @@ const DEFAULT_CONFIG = {
|
|
|
28
28
|
* Concurrent DNS Detection Engine
|
|
29
29
|
*/
|
|
30
30
|
class ConcurrentDNSDetector {
|
|
31
|
+
// Cleanup method for tests
|
|
32
|
+
cleanup() {
|
|
33
|
+
// Cancel any in-progress timeouts
|
|
34
|
+
const timeoutError = new Error('Operation cancelled by cleanup');
|
|
35
|
+
for (const { reject } of this.activeQueries) {
|
|
36
|
+
reject(timeoutError);
|
|
37
|
+
}
|
|
38
|
+
this.activeQueries.clear();
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
}
|
|
31
41
|
constructor(providers, config = {}) {
|
|
42
|
+
// Store active query states
|
|
43
|
+
this.activeQueries = new Set();
|
|
32
44
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
33
45
|
this.providers = providers.filter(p => p.customDomainDetection &&
|
|
34
46
|
(p.customDomainDetection.mxPatterns || p.customDomainDetection.txtPatterns));
|
|
@@ -38,7 +50,7 @@ class ConcurrentDNSDetector {
|
|
|
38
50
|
*/
|
|
39
51
|
async detectProvider(domain) {
|
|
40
52
|
const startTime = Date.now();
|
|
41
|
-
const normalizedDomain = domain.toLowerCase();
|
|
53
|
+
const normalizedDomain = domain.toLowerCase().trim().replace(/\.+$/, '');
|
|
42
54
|
// Initialize result
|
|
43
55
|
const result = {
|
|
44
56
|
provider: null,
|
|
@@ -378,13 +390,28 @@ class ConcurrentDNSDetector {
|
|
|
378
390
|
* Wrap a promise with a timeout
|
|
379
391
|
*/
|
|
380
392
|
withTimeout(promise, ms) {
|
|
381
|
-
|
|
382
|
-
|
|
393
|
+
let rejectFn;
|
|
394
|
+
const timeoutPromise = new Promise((resolve, reject) => {
|
|
395
|
+
rejectFn = reject;
|
|
396
|
+
const timeout = setTimeout(() => reject(new Error(`DNS query timeout after ${ms}ms`)), ms).unref();
|
|
383
397
|
promise
|
|
384
398
|
.then(resolve)
|
|
385
399
|
.catch(reject)
|
|
386
|
-
.finally(() =>
|
|
400
|
+
.finally(() => {
|
|
401
|
+
clearTimeout(timeout);
|
|
402
|
+
// Clean up active query
|
|
403
|
+
const queryEntry = Array.from(this.activeQueries).find(entry => entry.promise === timeoutPromise);
|
|
404
|
+
if (queryEntry) {
|
|
405
|
+
this.activeQueries.delete(queryEntry);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
387
408
|
});
|
|
409
|
+
// Only add to active queries if we have a reject function
|
|
410
|
+
if (rejectFn) {
|
|
411
|
+
const queryEntry = { promise: timeoutPromise, reject: rejectFn };
|
|
412
|
+
this.activeQueries.add(queryEntry);
|
|
413
|
+
}
|
|
414
|
+
return timeoutPromise;
|
|
388
415
|
}
|
|
389
416
|
}
|
|
390
417
|
exports.ConcurrentDNSDetector = ConcurrentDNSDetector;
|
|
@@ -29,7 +29,7 @@ const KNOWN_GOOD_HASHES = {
|
|
|
29
29
|
// SHA-256 hash of the legitimate emailproviders.json
|
|
30
30
|
'emailproviders.json': 'f77814bf0537019c6f38bf2744ae21640f04a2d39cb67c5116f6e03160c9486f',
|
|
31
31
|
// You can add hashes for other critical files
|
|
32
|
-
'package.json': '
|
|
32
|
+
'package.json': 'da14e15d9602b7e2cdf3d08f4756ca7ff9af842906e4de1af7664c1d1cce8e9e'
|
|
33
33
|
};
|
|
34
34
|
/**
|
|
35
35
|
* Calculates SHA-256 hash of a file or string content
|
|
@@ -140,7 +140,7 @@ function generateSecurityHashes(basePath = __dirname) {
|
|
|
140
140
|
const hashes = {};
|
|
141
141
|
for (const file of files) {
|
|
142
142
|
try {
|
|
143
|
-
const fullPath = (0, path_1.join)(basePath, '..',
|
|
143
|
+
const fullPath = (0, path_1.join)(basePath, '..', file);
|
|
144
144
|
const hash = calculateFileHash(fullPath);
|
|
145
145
|
hashes[file.split('/').pop() || file] = hash;
|
|
146
146
|
console.log(`✅ ${file}: ${hash}`);
|
|
@@ -231,7 +231,7 @@ function handleHashMismatch(result, options = {}) {
|
|
|
231
231
|
* @returns Complete security audit result
|
|
232
232
|
*/
|
|
233
233
|
function performSecurityAudit(providersFilePath) {
|
|
234
|
-
const filePath = providersFilePath || (0, path_1.join)(__dirname, '..', '
|
|
234
|
+
const filePath = providersFilePath || (0, path_1.join)(__dirname, '..', 'providers', 'emailproviders.json');
|
|
235
235
|
const hashResult = verifyProvidersIntegrity(filePath);
|
|
236
236
|
const recommendations = [];
|
|
237
237
|
let securityLevel = 'HIGH';
|
package/dist/idn.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IDN (Internationalized Domain Names) utilities
|
|
3
|
+
* Zero-dependency implementation for domain name handling
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Convert domain to Punycode format
|
|
7
|
+
* @param domain Domain name to convert
|
|
8
|
+
* @returns Punycode encoded domain
|
|
9
|
+
*/
|
|
10
|
+
export declare function domainToPunycode(domain: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Convert email address's domain to Punycode
|
|
13
|
+
* @param email Email address to convert
|
|
14
|
+
* @returns Email with Punycode encoded domain
|
|
15
|
+
*/
|
|
16
|
+
export declare function emailToPunycode(email: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Error codes for IDN validation
|
|
19
|
+
* These can be used as keys for translation systems
|
|
20
|
+
*/
|
|
21
|
+
export declare enum IDNValidationError {
|
|
22
|
+
MISSING_INPUT = "MISSING_INPUT",
|
|
23
|
+
EMAIL_TOO_LONG = "EMAIL_TOO_LONG",
|
|
24
|
+
MISSING_AT_SYMBOL = "MISSING_AT_SYMBOL",
|
|
25
|
+
LOCAL_PART_EMPTY = "LOCAL_PART_EMPTY",
|
|
26
|
+
LOCAL_PART_TOO_LONG = "LOCAL_PART_TOO_LONG",
|
|
27
|
+
LOCAL_PART_INVALID = "LOCAL_PART_INVALID",
|
|
28
|
+
DOMAIN_EMPTY = "DOMAIN_EMPTY",
|
|
29
|
+
DOMAIN_TOO_LONG = "DOMAIN_TOO_LONG",
|
|
30
|
+
DOMAIN_INVALID_FORMAT = "DOMAIN_INVALID_FORMAT",
|
|
31
|
+
MISSING_TLD = "MISSING_TLD",
|
|
32
|
+
NUMERIC_TLD = "NUMERIC_TLD",
|
|
33
|
+
INVALID_ENCODING = "INVALID_ENCODING"
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validates an email address according to international standards (IDNA)
|
|
37
|
+
* This implementation follows RFC 5321, 5322, and 6530 standards for email addresses
|
|
38
|
+
*
|
|
39
|
+
* @param email The email address to validate
|
|
40
|
+
* @returns Error information if validation fails, undefined if valid
|
|
41
|
+
*/
|
|
42
|
+
export declare function validateInternationalEmail(email: string): {
|
|
43
|
+
type: 'IDN_VALIDATION_ERROR';
|
|
44
|
+
code: IDNValidationError;
|
|
45
|
+
message: string;
|
|
46
|
+
} | undefined;
|
package/dist/idn.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* IDN (Internationalized Domain Names) utilities
|
|
4
|
+
* Zero-dependency implementation for domain name handling
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.IDNValidationError = void 0;
|
|
8
|
+
exports.domainToPunycode = domainToPunycode;
|
|
9
|
+
exports.emailToPunycode = emailToPunycode;
|
|
10
|
+
exports.validateInternationalEmail = validateInternationalEmail;
|
|
11
|
+
const BASE = 36;
|
|
12
|
+
const INITIAL_N = 128;
|
|
13
|
+
const INITIAL_BIAS = 72;
|
|
14
|
+
const DAMP = 700;
|
|
15
|
+
const TMIN = 1;
|
|
16
|
+
const TMAX = 26;
|
|
17
|
+
const SKEW = 38;
|
|
18
|
+
const DELIMITER = '-';
|
|
19
|
+
function adaptBias(delta, numPoints, firstTime) {
|
|
20
|
+
delta = firstTime ? Math.floor(delta / DAMP) : Math.floor(delta / 2);
|
|
21
|
+
delta += Math.floor(delta / numPoints);
|
|
22
|
+
let k = 0;
|
|
23
|
+
while (delta > ((BASE - TMIN) * TMAX) / 2) {
|
|
24
|
+
delta = Math.floor(delta / (BASE - TMIN));
|
|
25
|
+
k += BASE;
|
|
26
|
+
}
|
|
27
|
+
return k + Math.floor(((BASE - TMIN + 1) * delta) / (delta + SKEW));
|
|
28
|
+
}
|
|
29
|
+
function digitToBasic(digit) {
|
|
30
|
+
return digit + 22 + 75 * Number(digit < 26);
|
|
31
|
+
}
|
|
32
|
+
function encode(str) {
|
|
33
|
+
const codePoints = Array.from(str).map(c => c.codePointAt(0));
|
|
34
|
+
let n = INITIAL_N;
|
|
35
|
+
let delta = 0;
|
|
36
|
+
let bias = INITIAL_BIAS;
|
|
37
|
+
let output = '';
|
|
38
|
+
// Copy ASCII chars directly
|
|
39
|
+
const basic = codePoints.filter(c => c < 0x80);
|
|
40
|
+
let h = basic.length;
|
|
41
|
+
let b = h;
|
|
42
|
+
if (b > 0) {
|
|
43
|
+
output = String.fromCodePoint(...basic);
|
|
44
|
+
}
|
|
45
|
+
if (b > 0) {
|
|
46
|
+
output += DELIMITER;
|
|
47
|
+
}
|
|
48
|
+
while (h < codePoints.length) {
|
|
49
|
+
let m = Number.MAX_SAFE_INTEGER;
|
|
50
|
+
for (const c of codePoints) {
|
|
51
|
+
if (c >= n && c < m)
|
|
52
|
+
m = c;
|
|
53
|
+
}
|
|
54
|
+
delta += (m - n) * (h + 1);
|
|
55
|
+
n = m;
|
|
56
|
+
for (const c of codePoints) {
|
|
57
|
+
if (c < n) {
|
|
58
|
+
delta++;
|
|
59
|
+
}
|
|
60
|
+
else if (c === n) {
|
|
61
|
+
let q = delta;
|
|
62
|
+
for (let k = BASE;; k += BASE) {
|
|
63
|
+
const t = k <= bias ? TMIN : k >= bias + TMAX ? TMAX : k - bias;
|
|
64
|
+
if (q < t)
|
|
65
|
+
break;
|
|
66
|
+
output += String.fromCodePoint(digitToBasic(t + (q - t) % (BASE - t)));
|
|
67
|
+
q = Math.floor((q - t) / (BASE - t));
|
|
68
|
+
}
|
|
69
|
+
output += String.fromCodePoint(digitToBasic(q));
|
|
70
|
+
bias = adaptBias(delta, h + 1, h === b);
|
|
71
|
+
delta = 0;
|
|
72
|
+
h++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
delta++;
|
|
76
|
+
n++;
|
|
77
|
+
}
|
|
78
|
+
return output;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Convert domain to Punycode format
|
|
82
|
+
* @param domain Domain name to convert
|
|
83
|
+
* @returns Punycode encoded domain
|
|
84
|
+
*/
|
|
85
|
+
function domainToPunycode(domain) {
|
|
86
|
+
// Split domain into labels
|
|
87
|
+
return domain.toLowerCase().split('.').map(label => {
|
|
88
|
+
// Check if label needs encoding (contains non-ASCII)
|
|
89
|
+
if (!/[^\x00-\x7F]/.test(label)) {
|
|
90
|
+
return label;
|
|
91
|
+
}
|
|
92
|
+
return 'xn--' + encode(label);
|
|
93
|
+
}).join('.');
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Convert email address's domain to Punycode
|
|
97
|
+
* @param email Email address to convert
|
|
98
|
+
* @returns Email with Punycode encoded domain
|
|
99
|
+
*/
|
|
100
|
+
function emailToPunycode(email) {
|
|
101
|
+
const [local, domain] = email.split('@');
|
|
102
|
+
if (!domain)
|
|
103
|
+
return email;
|
|
104
|
+
return `${local}@${domainToPunycode(domain)}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Error codes for IDN validation
|
|
108
|
+
* These can be used as keys for translation systems
|
|
109
|
+
*/
|
|
110
|
+
var IDNValidationError;
|
|
111
|
+
(function (IDNValidationError) {
|
|
112
|
+
IDNValidationError["MISSING_INPUT"] = "MISSING_INPUT";
|
|
113
|
+
IDNValidationError["EMAIL_TOO_LONG"] = "EMAIL_TOO_LONG";
|
|
114
|
+
IDNValidationError["MISSING_AT_SYMBOL"] = "MISSING_AT_SYMBOL";
|
|
115
|
+
IDNValidationError["LOCAL_PART_EMPTY"] = "LOCAL_PART_EMPTY";
|
|
116
|
+
IDNValidationError["LOCAL_PART_TOO_LONG"] = "LOCAL_PART_TOO_LONG";
|
|
117
|
+
IDNValidationError["LOCAL_PART_INVALID"] = "LOCAL_PART_INVALID";
|
|
118
|
+
IDNValidationError["DOMAIN_EMPTY"] = "DOMAIN_EMPTY";
|
|
119
|
+
IDNValidationError["DOMAIN_TOO_LONG"] = "DOMAIN_TOO_LONG";
|
|
120
|
+
IDNValidationError["DOMAIN_INVALID_FORMAT"] = "DOMAIN_INVALID_FORMAT";
|
|
121
|
+
IDNValidationError["MISSING_TLD"] = "MISSING_TLD";
|
|
122
|
+
IDNValidationError["NUMERIC_TLD"] = "NUMERIC_TLD";
|
|
123
|
+
IDNValidationError["INVALID_ENCODING"] = "INVALID_ENCODING";
|
|
124
|
+
})(IDNValidationError || (exports.IDNValidationError = IDNValidationError = {}));
|
|
125
|
+
/**
|
|
126
|
+
* Validates an email address according to international standards (IDNA)
|
|
127
|
+
* This implementation follows RFC 5321, 5322, and 6530 standards for email addresses
|
|
128
|
+
*
|
|
129
|
+
* @param email The email address to validate
|
|
130
|
+
* @returns Error information if validation fails, undefined if valid
|
|
131
|
+
*/
|
|
132
|
+
function validateInternationalEmail(email) {
|
|
133
|
+
// Basic checks
|
|
134
|
+
if (!email || typeof email !== 'string') {
|
|
135
|
+
return {
|
|
136
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
137
|
+
code: IDNValidationError.MISSING_INPUT,
|
|
138
|
+
message: 'The email field cannot be empty'
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// Split into local and domain parts
|
|
142
|
+
const atIndex = email.lastIndexOf('@');
|
|
143
|
+
if (atIndex === -1) {
|
|
144
|
+
return {
|
|
145
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
146
|
+
code: IDNValidationError.MISSING_AT_SYMBOL,
|
|
147
|
+
message: 'The email address must contain an @ symbol'
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const local = email.slice(0, atIndex);
|
|
151
|
+
const domain = email.slice(atIndex + 1);
|
|
152
|
+
// Check for max length - RFC 5321
|
|
153
|
+
if (email.length > 254) {
|
|
154
|
+
return {
|
|
155
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
156
|
+
code: IDNValidationError.EMAIL_TOO_LONG,
|
|
157
|
+
message: 'The email address is too long'
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// Validate domain part
|
|
161
|
+
if (domain.length === 0) {
|
|
162
|
+
return {
|
|
163
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
164
|
+
code: IDNValidationError.DOMAIN_EMPTY,
|
|
165
|
+
message: 'The domain part of the email cannot be empty'
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (domain.length > 255) {
|
|
169
|
+
return {
|
|
170
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
171
|
+
code: IDNValidationError.DOMAIN_TOO_LONG,
|
|
172
|
+
message: 'The domain part of the email is too long'
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// Validate local part
|
|
176
|
+
if (local.length === 0) {
|
|
177
|
+
return {
|
|
178
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
179
|
+
code: IDNValidationError.LOCAL_PART_EMPTY,
|
|
180
|
+
message: 'The username part of the email cannot be empty'
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (local.length > 64) {
|
|
184
|
+
return {
|
|
185
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
186
|
+
code: IDNValidationError.LOCAL_PART_TOO_LONG,
|
|
187
|
+
message: 'The username part of the email is too long'
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Check local part characters
|
|
191
|
+
// Allows: letters, numbers, and !#$%&'*+-/=?^_`{|}~.
|
|
192
|
+
// Dot can't be first, last, or consecutive
|
|
193
|
+
if (!/^[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]([a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~.]*[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~])?$/.test(local) || local.includes('..')) {
|
|
194
|
+
return {
|
|
195
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
196
|
+
code: IDNValidationError.LOCAL_PART_INVALID,
|
|
197
|
+
message: 'The username contains invalid characters or dots in wrong places'
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
// Check domain format (including IDN domains)
|
|
201
|
+
try {
|
|
202
|
+
// Check for lone surrogates and control characters
|
|
203
|
+
if (/[\uD800-\uDFFF]/.test(domain)) {
|
|
204
|
+
return {
|
|
205
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
206
|
+
code: IDNValidationError.INVALID_ENCODING,
|
|
207
|
+
message: 'The domain contains invalid characters or encoding'
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// Convert to punycode to handle IDN
|
|
211
|
+
const punycodeDomain = domainToPunycode(domain);
|
|
212
|
+
// Check basic domain format
|
|
213
|
+
// Allows: letters, numbers, hyphens (not first/last), dots separating labels
|
|
214
|
+
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(punycodeDomain)) {
|
|
215
|
+
return {
|
|
216
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
217
|
+
code: IDNValidationError.DOMAIN_INVALID_FORMAT,
|
|
218
|
+
message: 'The domain format is invalid'
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// Check if domain has at least one dot (TLD required)
|
|
222
|
+
if (!punycodeDomain.includes('.')) {
|
|
223
|
+
return {
|
|
224
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
225
|
+
code: IDNValidationError.MISSING_TLD,
|
|
226
|
+
message: 'The email domain must include a top-level domain (like .com or .org)'
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// Check TLD is not all-numeric
|
|
230
|
+
const tld = punycodeDomain.split('.').pop();
|
|
231
|
+
if (/^[0-9]+$/.test(tld)) {
|
|
232
|
+
return {
|
|
233
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
234
|
+
code: IDNValidationError.NUMERIC_TLD,
|
|
235
|
+
message: 'The top-level domain cannot be all numbers'
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
return {
|
|
241
|
+
type: 'IDN_VALIDATION_ERROR',
|
|
242
|
+
code: IDNValidationError.INVALID_ENCODING,
|
|
243
|
+
message: 'The domain contains invalid characters or encoding'
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// If we get here, the email is valid
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Integrates URL validation and hash verification to create a secure
|
|
5
5
|
* loading system for email provider data.
|
|
6
6
|
*/
|
|
7
|
-
import type { EmailProvider } from '
|
|
7
|
+
import type { EmailProvider } from './index';
|
|
8
8
|
export interface SecureLoadResult {
|
|
9
9
|
success: boolean;
|
|
10
10
|
providers: EmailProvider[];
|
|
@@ -33,11 +33,13 @@ export declare function initializeSecurity(): Record<string, string>;
|
|
|
33
33
|
/**
|
|
34
34
|
* Express middleware for secure provider loading (if using in web apps)
|
|
35
35
|
*/
|
|
36
|
-
|
|
36
|
+
interface SecurityMiddlewareOptions {
|
|
37
37
|
expectedHash?: string;
|
|
38
38
|
allowInvalidUrls?: boolean;
|
|
39
39
|
onSecurityIssue?: (report: SecureLoadResult['securityReport']) => void;
|
|
40
|
-
|
|
40
|
+
getProviders?: () => SecureLoadResult;
|
|
41
|
+
}
|
|
42
|
+
export declare function createSecurityMiddleware(options?: SecurityMiddlewareOptions): (req: any, res: any, next: any) => any;
|
|
41
43
|
declare const _default: {
|
|
42
44
|
secureLoadProviders: typeof secureLoadProviders;
|
|
43
45
|
initializeSecurity: typeof initializeSecurity;
|
|
@@ -21,7 +21,7 @@ const hash_verifier_1 = require("./hash-verifier");
|
|
|
21
21
|
* @returns Secure load result with validation details
|
|
22
22
|
*/
|
|
23
23
|
function secureLoadProviders(providersPath, expectedHash) {
|
|
24
|
-
const filePath = providersPath || (0, path_1.join)(__dirname, '..', '
|
|
24
|
+
const filePath = providersPath || (0, path_1.join)(__dirname, '..', 'providers', 'emailproviders.json');
|
|
25
25
|
const issues = [];
|
|
26
26
|
let providers = [];
|
|
27
27
|
// Step 1: Hash verification
|
|
@@ -118,12 +118,11 @@ function initializeSecurity() {
|
|
|
118
118
|
console.log('\n⚠️ Remember to update hashes when making legitimate changes to provider data!');
|
|
119
119
|
return hashes;
|
|
120
120
|
}
|
|
121
|
-
/**
|
|
122
|
-
* Express middleware for secure provider loading (if using in web apps)
|
|
123
|
-
*/
|
|
124
121
|
function createSecurityMiddleware(options = {}) {
|
|
125
122
|
return (req, res, next) => {
|
|
126
|
-
|
|
123
|
+
// If a custom providers getter is provided, use that instead of loading from file
|
|
124
|
+
const result = options.getProviders ? options.getProviders() : secureLoadProviders(undefined, options.expectedHash);
|
|
125
|
+
// Handle security level
|
|
127
126
|
if (result.securityReport.securityLevel === 'CRITICAL' && !options.allowInvalidUrls) {
|
|
128
127
|
if (options.onSecurityIssue) {
|
|
129
128
|
options.onSecurityIssue(result.securityReport);
|
|
@@ -134,6 +134,7 @@ const URL_SHORTENERS = [
|
|
|
134
134
|
'is.gd',
|
|
135
135
|
'buff.ly'
|
|
136
136
|
];
|
|
137
|
+
const idn_1 = require("./idn");
|
|
137
138
|
/**
|
|
138
139
|
* Validates if a URL is safe for email provider redirects
|
|
139
140
|
*
|
|
@@ -179,7 +180,7 @@ function validateEmailProviderUrl(url) {
|
|
|
179
180
|
}
|
|
180
181
|
// Parse and normalize the URL
|
|
181
182
|
const urlObj = new URL(url);
|
|
182
|
-
const domain = urlObj.hostname.toLowerCase();
|
|
183
|
+
const domain = (0, idn_1.domainToPunycode)(urlObj.hostname.toLowerCase());
|
|
183
184
|
const normalizedUrl = urlObj.toString();
|
|
184
185
|
// Must use HTTPS
|
|
185
186
|
if (urlObj.protocol !== 'https:') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikkelscheike/email-provider-links",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "TypeScript library for email provider detection with 93 providers (178 domains), concurrent DNS resolution, optimized performance, 91.75% test coverage, and enterprise security for login and password reset flows",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
"dist/**/*.js",
|
|
9
9
|
"dist/**/*.d.ts",
|
|
10
10
|
"providers/emailproviders.json",
|
|
11
|
-
"!dist/**/*.map"
|
|
12
|
-
"!dist/**/security-demo.*"
|
|
11
|
+
"!dist/**/*.map"
|
|
13
12
|
],
|
|
14
13
|
"scripts": {
|
|
15
14
|
"build": "tsc",
|
|
@@ -24,7 +23,8 @@
|
|
|
24
23
|
"update-hashes": "tsx scripts/update-hashes.ts",
|
|
25
24
|
"release:major": "tsx scripts/prepare-release.ts",
|
|
26
25
|
"release:minor": "tsx scripts/prepare-release.ts",
|
|
27
|
-
"release:patch": "tsx scripts/prepare-release.ts"
|
|
26
|
+
"release:patch": "tsx scripts/prepare-release.ts",
|
|
27
|
+
"benchmark": "tsx --expose-gc benchmark/memory.ts"
|
|
28
28
|
},
|
|
29
29
|
"keywords": [
|
|
30
30
|
"email",
|
|
@@ -60,10 +60,10 @@
|
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public"
|
|
62
62
|
},
|
|
63
|
-
"packageManager": "pnpm@10.11.1",
|
|
64
63
|
"devDependencies": {
|
|
64
|
+
"@jest/globals": "^30.0.2",
|
|
65
65
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
66
|
-
"@semantic-release/exec": "^
|
|
66
|
+
"@semantic-release/exec": "^7.1.0",
|
|
67
67
|
"@semantic-release/git": "^10.0.1",
|
|
68
68
|
"@semantic-release/github": "^11.0.3",
|
|
69
69
|
"@semantic-release/npm": "^12.0.1",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"@types/jest": "^30.0.0",
|
|
72
72
|
"@types/node": "^24.0.3",
|
|
73
73
|
"conventional-changelog-conventionalcommits": "^9.0.0",
|
|
74
|
-
"jest": "^30.0.
|
|
74
|
+
"jest": "^30.0.2",
|
|
75
75
|
"semantic-release": "^24.2.5",
|
|
76
76
|
"ts-jest": "^29.4.0",
|
|
77
77
|
"tsx": "^4.20.3",
|
|
File without changes
|
|
File without changes
|