@mikkelscheike/email-provider-links 2.5.1 → 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 CHANGED
@@ -6,10 +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, minimal footprint
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
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
13
14
  - 🏢 **Business Domain Detection**: DNS-based detection for custom domains (Google Workspace, Microsoft 365, etc.)
14
15
  - 🔒 **Enterprise Security**: Multi-layer protection against malicious URLs and supply chain attacks
15
16
  - 🛡️ **URL Validation**: HTTPS-only enforcement with domain allowlisting
@@ -28,15 +29,9 @@ Using npm:
28
29
  npm install @mikkelscheike/email-provider-links
29
30
  ```
30
31
 
31
- Using pnpm:
32
- ```bash
33
- pnpm add @mikkelscheike/email-provider-links
34
- ```
35
-
36
32
  ## Requirements
37
33
 
38
34
  - **Node.js**: `>=18.0.0` (Tested on 18.x, 20.x, 22.x, **24.x**)
39
- - **Package Managers**: npm and pnpm (Tested on pnpm 18.x through 24.x)
40
35
  - **TypeScript**: `>=4.0.0` (optional)
41
36
  - **Zero runtime dependencies** - No external packages required
42
37
 
@@ -160,6 +155,39 @@ console.log('Max requests:', Config.MAX_DNS_REQUESTS_PER_MINUTE); // 10
160
155
  console.log('Default timeout:', Config.DEFAULT_DNS_TIMEOUT); // 5000ms
161
156
  ```
162
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
+
163
191
  ## Advanced Usage
164
192
 
165
193
  <details>
@@ -262,6 +290,12 @@ interface EmailProviderResult {
262
290
  loginUrl: string | null;
263
291
  detectionMethod?: 'domain_match' | 'mx_record' | 'txt_record' | 'proxy_detected';
264
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
+ };
265
299
  }
266
300
 
267
301
  interface ConfigConstants {
@@ -318,6 +352,24 @@ if (result.securityReport.securityLevel === 'CRITICAL') {
318
352
  }
319
353
  ```
320
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
+
321
373
  ## Contributing
322
374
 
323
375
  We welcome contributions! See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for guidelines on adding new email providers.
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
  /**
@@ -69,7 +69,7 @@ export interface ConcurrentDNSResult {
69
69
  */
70
70
  export declare class ConcurrentDNSDetector {
71
71
  private activeQueries;
72
- cleanup(): void;
72
+ cleanup(): Promise<void>;
73
73
  private config;
74
74
  private providers;
75
75
  constructor(providers: EmailProvider[], config?: Partial<ConcurrentDNSConfig>);
@@ -30,7 +30,13 @@ const DEFAULT_CONFIG = {
30
30
  class ConcurrentDNSDetector {
31
31
  // Cleanup method for tests
32
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
+ }
33
38
  this.activeQueries.clear();
39
+ return Promise.resolve();
34
40
  }
35
41
  constructor(providers, config = {}) {
36
42
  // Store active query states
@@ -384,13 +390,28 @@ class ConcurrentDNSDetector {
384
390
  * Wrap a promise with a timeout
385
391
  */
386
392
  withTimeout(promise, ms) {
387
- return new Promise((resolve, reject) => {
388
- const timeout = setTimeout(() => reject(new Error(`DNS query timeout after ${ms}ms`)), ms);
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();
389
397
  promise
390
398
  .then(resolve)
391
399
  .catch(reject)
392
- .finally(() => clearTimeout(timeout));
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
+ });
393
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;
394
415
  }
395
416
  }
396
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': '92cc46fb47a1466bf3f448cd2f289d51e65daf7e7a93b389e956a49b6ffc4bbe'
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, '..', '..', file);
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, '..', '..', 'providers', 'emailproviders.json');
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 CHANGED
@@ -14,3 +14,33 @@ export declare function domainToPunycode(domain: string): string;
14
14
  * @returns Email with Punycode encoded domain
15
15
  */
16
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 CHANGED
@@ -4,8 +4,10 @@
4
4
  * Zero-dependency implementation for domain name handling
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.IDNValidationError = void 0;
7
8
  exports.domainToPunycode = domainToPunycode;
8
9
  exports.emailToPunycode = emailToPunycode;
10
+ exports.validateInternationalEmail = validateInternationalEmail;
9
11
  const BASE = 36;
10
12
  const INITIAL_N = 128;
11
13
  const INITIAL_BIAS = 72;
@@ -101,3 +103,146 @@ function emailToPunycode(email) {
101
103
  return email;
102
104
  return `${local}@${domainToPunycode(domain)}`;
103
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 '../index';
7
+ import type { EmailProvider } from './index';
8
8
  export interface SecureLoadResult {
9
9
  success: boolean;
10
10
  providers: EmailProvider[];
@@ -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, '..', '..', 'providers', 'emailproviders.json');
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
@@ -134,7 +134,7 @@ const URL_SHORTENERS = [
134
134
  'is.gd',
135
135
  'buff.ly'
136
136
  ];
137
- const idn_1 = require("../idn");
137
+ const idn_1 = require("./idn");
138
138
  /**
139
139
  * Validates if a URL is safe for email provider redirects
140
140
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikkelscheike/email-provider-links",
3
- "version": "2.5.1",
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",
@@ -61,13 +61,13 @@
61
61
  "access": "public"
62
62
  },
63
63
  "devDependencies": {
64
+ "@jest/globals": "^30.0.2",
64
65
  "@semantic-release/commit-analyzer": "^13.0.1",
65
- "@semantic-release/exec": "^6.0.3",
66
+ "@semantic-release/exec": "^7.1.0",
66
67
  "@semantic-release/git": "^10.0.1",
67
68
  "@semantic-release/github": "^11.0.3",
68
69
  "@semantic-release/npm": "^12.0.1",
69
70
  "@semantic-release/release-notes-generator": "^14.0.3",
70
- "@jest/globals": "^30.0.2",
71
71
  "@types/jest": "^30.0.0",
72
72
  "@types/node": "^24.0.3",
73
73
  "conventional-changelog-conventionalcommits": "^9.0.0",
@@ -1 +0,0 @@
1
- export {};
@@ -1,8 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- const globals_1 = require("@jest/globals");
4
- (0, globals_1.describe)('Basic Test', () => {
5
- (0, globals_1.test)('true should be true', () => {
6
- (0, globals_1.expect)(true).toBe(true);
7
- });
8
- });