├── .npmrc ├── src ├── constants │ ├── index.ts │ └── mime-types.ts ├── index.ts ├── types │ └── index.ts └── mailer │ └── mailer.ts ├── .prettierignore ├── jest.config.js ├── .prettierrc ├── .env.example ├── tsconfig.json ├── .gitignore ├── LICENCE ├── rollup.config.js ├── package.json ├── tests ├── attachment.test.ts ├── logging.test.ts ├── ratelimit.test.ts └── mailer.test.ts └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mime-types'; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FastMailer } from './mailer/mailer'; 2 | export { MailerConfig, Attachment, MailOptions, SendResult, Metrics } from './types'; 3 | export { mimeTypes } from './constants'; 4 | 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | build/ 3 | dist/ 4 | coverage/ 5 | 6 | # Dependencies 7 | node_modules/ 8 | 9 | # Package files 10 | package-lock.json 11 | yarn.lock 12 | 13 | # Other 14 | .git/ 15 | .gitignore 16 | .DS_Store -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/tests/**/*.test.ts'], 5 | collectCoverageFrom: ['src/**/*.ts'], 6 | coverageThreshold: { 7 | global: { 8 | branches: 80, 9 | functions: 80, 10 | lines: 80, 11 | statements: 80, 12 | }, 13 | }, 14 | }; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "arrowParens": "always", 10 | "endOfLine": "lf", 11 | "bracketSameLine": false, 12 | "jsxSingleQuote": false, 13 | "quoteProps": "as-needed", 14 | "proseWrap": "preserve", 15 | "htmlWhitespaceSensitivity": "css", 16 | "embeddedLanguageFormatting": "auto" 17 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # SMTP Configuration 2 | EMAIL_USER= 3 | FROM= 4 | TO= 5 | TO_2= 6 | CC= 7 | BCC= 8 | 9 | # Logging Configuration 10 | LOG_LEVEL=debug 11 | LOG_FORMAT=json 12 | LOG_FILE=./logs/mailer.log 13 | 14 | # Rate Limiting 15 | RATE_LIMIT_BURST=100 16 | RATE_LIMIT_COOLDOWN=3600 17 | RATE_LIMIT_BAN_DURATION=86400 18 | MAX_CONSECUTIVE_FAILURES=5 19 | 20 | # Connection Settings 21 | SMTP_HOST=smtp.gmail.com 22 | SMTP_PORT=465 23 | SOCKET_TIMEOUT=5000 24 | POOL_SIZE=5 25 | KEEP_ALIVE=true 26 | RETRY_ATTEMPTS=3 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "esnext", 5 | "lib": ["es2018", "dom"], 6 | "declaration": true, 7 | "declarationDir": null, 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "rootDir": "src", 11 | "strict": true, 12 | "moduleResolution": "node", 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": "src" 17 | }, 18 | "include": ["src"], 19 | "exclude": ["node_modules", "dist", "tests", "images"] 20 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | jspm_packages/ 4 | 5 | # Build outputs 6 | dist/ 7 | build/ 8 | out/ 9 | 10 | .env.test 11 | 12 | # Environment files 13 | .env 14 | .env.local 15 | .env.*.local 16 | 17 | # IDE and editor files 18 | .idea/ 19 | .vscode/ 20 | *.swp 21 | *.swo 22 | .DS_Store 23 | Thumbs.db 24 | 25 | # Debug logs 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | logs/ 30 | *.log 31 | 32 | # Testing 33 | coverage/ 34 | .nyc_output/ 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional eslint cache 40 | .eslintcache 41 | 42 | # Optional REPL history 43 | .node_repl_history 44 | 45 | # Output of 'npm pack' 46 | *.tgz 47 | 48 | # Yarn Integrity file 49 | .yarn-integrity 50 | 51 | # parcel-bundler cache 52 | .cache 53 | .parcel-cache 54 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Fast-Mailer 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. 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const resolve = require('@rollup/plugin-node-resolve'); 2 | const commonjs = require('@rollup/plugin-commonjs'); 3 | const typescript = require('@rollup/plugin-typescript'); 4 | const { default: dts } = require('rollup-plugin-dts'); 5 | 6 | const packageJson = require('./package.json'); 7 | 8 | module.exports = [ 9 | { 10 | input: 'src/index.ts', 11 | output: [ 12 | { 13 | file: packageJson.main, 14 | format: 'cjs', 15 | sourcemap: true, 16 | }, 17 | { 18 | file: packageJson.module, 19 | format: 'esm', 20 | sourcemap: true, 21 | }, 22 | ], 23 | external: ['events', 'net', 'tls', 'dns', 'fs', 'path', 'tslib'], 24 | plugins: [ 25 | resolve.default(), 26 | commonjs(), 27 | typescript({ 28 | tsconfig: './tsconfig.json', 29 | declaration: true, 30 | declarationDir: null, // This prevents creating separate folders 31 | rootDir: 'src', 32 | }), 33 | ], 34 | }, 35 | { 36 | input: 'src/index.ts', 37 | output: [{ file: 'dist/index.d.ts', format: 'esm' }], 38 | plugins: [dts()], 39 | }, 40 | ]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fast-mailer", 3 | "version": "2.0.0", 4 | "description": "High-performance, intelligent email delivery system powered by advanced technology", 5 | "main": "dist/index.js", 6 | "module": "dist/index.esm.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "keywords": [ 12 | "tensor", 13 | "email", 14 | "smtp", 15 | "mailer", 16 | "nodejs", 17 | "typescript", 18 | "intelligent-mail", 19 | "high-performance" 20 | ], 21 | "scripts": { 22 | "build": "rimraf dist && rollup -c", 23 | "test": "jest", 24 | "test:watch": "jest --watch", 25 | "test:coverage": "jest --coverage", 26 | "prepublishOnly": "npm run test && npm run build" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/youssef-of-web/fast-mailer" 31 | }, 32 | "author": "Youssef Mansouri", 33 | "license": "MIT", 34 | "devDependencies": { 35 | "@rollup/plugin-commonjs": "^22.0.0", 36 | "@rollup/plugin-node-resolve": "^13.3.0", 37 | "@rollup/plugin-typescript": "^8.3.2", 38 | "@types/chai": "^5.0.1", 39 | "@types/jest": "^28.1.8", 40 | "@types/morgan": "^1.9.9", 41 | "@types/node": "^17.0.41", 42 | "chai": "^5.1.2", 43 | "dotenv": "^16.4.5", 44 | "jest": "^28.1.3", 45 | "rimraf": "^6.0.1", 46 | "rollup": "^2.75.6", 47 | "rollup-plugin-dts": "^4.2.2", 48 | "ts-jest": "^28.0.8", 49 | "tslib": "^2.8.1", 50 | "typescript": "^4.7.3" 51 | }, 52 | "peerDependencies": { 53 | "events": "^3.3.0", 54 | "morgan": "^1.10.0" 55 | }, 56 | "dependencies": { 57 | "events": "^3.3.0", 58 | "morgan": "^1.10.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/attachment.test.ts: -------------------------------------------------------------------------------- 1 | import { FastMailer } from '../src'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import dotenv from 'dotenv'; 5 | 6 | dotenv.config({path: path.join(__dirname, '../.env.test')}); 7 | 8 | describe('Attachment Tests', () => { 9 | const testFiles = { 10 | text: path.join(__dirname, 'mailer.test.ts') 11 | }; 12 | 13 | beforeAll(() => { 14 | // Verify test files exist 15 | Object.entries(testFiles).forEach(([type, filePath]) => { 16 | if (!fs.existsSync(filePath)) { 17 | throw new Error(`Test file not found: ${filePath}`); 18 | } 19 | }); 20 | }); 21 | 22 | test('should successfully send email with multiple attachments', async () => { 23 | const mailer = new FastMailer({ 24 | host: process.env.SMTP_HOST || '', 25 | port: parseInt(process.env.SMTP_PORT || '465'), 26 | secure: true, 27 | auth: { 28 | user: process.env.EMAIL_USER || '', 29 | pass: process.env.EMAIL_PASS || '' 30 | }, 31 | from: process.env.FROM || '', 32 | logging: { 33 | level: 'debug', 34 | format: 'json' 35 | } 36 | }); 37 | 38 | const result = await mailer.sendMail({ 39 | to: process.env.TO || '', 40 | subject: 'Test Email with Attachments', 41 | text: 'This is a test email with multiple attachment types', 42 | attachments: [ 43 | { 44 | path: testFiles.text, 45 | contentType: 'text/plain' 46 | } 47 | ] 48 | }); 49 | 50 | expect(result.success).toBe(true); 51 | expect(result.messageId).toBeDefined(); 52 | expect(result.timestamp).toBeInstanceOf(Date); 53 | expect(result.recipients).toBeDefined(); 54 | 55 | const metrics = mailer.getMetrics(); 56 | expect(metrics.errors_by_type.attachment).toBe(0); 57 | }, 30000); 58 | }); -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface MailerConfig { 2 | host: string; 3 | port: number; 4 | secure?: boolean; 5 | auth: { 6 | user: string; 7 | pass: string; 8 | }; 9 | /** Email address to send mail from */ 10 | from: string; 11 | /** Number of times to retry failed email sends (default: 3) */ 12 | retryAttempts?: number; 13 | /** Socket timeout in milliseconds (default: 5000) */ 14 | timeout?: number; 15 | /** Whether to keep connection alive between sends (default: false) */ 16 | keepAlive?: boolean; 17 | /** Maximum number of simultaneous connections to maintain (default: 5) */ 18 | poolSize?: number; 19 | /** Rate limiting configuration */ 20 | rateLimiting?: { 21 | perRecipient?: boolean; 22 | burstLimit?: number; 23 | cooldownPeriod?: number; 24 | banDuration?: number; 25 | maxConsecutiveFailures?: number; 26 | failureCooldown?: number; 27 | maxRapidAttempts?: number; 28 | rapidPeriod?: number; 29 | }; 30 | logging?: { 31 | level?: 'debug' | 'info' | 'warn' | 'error'; 32 | format?: 'json' | 'text'; 33 | customFields?: string[]; 34 | destination?: string; 35 | }; 36 | } 37 | 38 | export interface Attachment { 39 | filename?: string; // Optional since we may extract from path 40 | path?: string; // File path (absolute or relative) 41 | content?: string | Buffer; 42 | contentType?: string; 43 | encoding?: string; 44 | } 45 | 46 | export interface MailOptions { 47 | to: string | string[]; 48 | subject: string; 49 | text?: string; 50 | html?: string; 51 | attachments?: Attachment[]; 52 | cc?: string | string[]; 53 | bcc?: string | string[]; 54 | priority?: 'high' | 'normal' | 'low'; 55 | headers?: { [key: string]: string }; 56 | } 57 | 58 | export interface SendResult { 59 | success: boolean; 60 | messageId?: string; 61 | error?: { 62 | code: string; 63 | message: string; 64 | details?: { 65 | type: 'connection_error' | 'authentication_error' | 'rate_limit_error' | 'validation_error' | 'timeout_error' | 'attachment_error' | 'command_error' | 'unknown_error'; 66 | context: any; 67 | timestamp: string; 68 | attemptNumber?: number; 69 | socketState?: string; 70 | lastCommand?: string; 71 | serverResponse?: string; 72 | }; 73 | }; 74 | recipients: string; 75 | timestamp: Date; 76 | } 77 | 78 | export interface Metrics { 79 | // Counter metrics 80 | emails_total: number; 81 | emails_successful: number; 82 | emails_failed: number; 83 | failed_emails: { 84 | timestamp: Date; 85 | recipient: string; 86 | subject: string; 87 | error: { 88 | code: string; 89 | message: string; 90 | details: any; 91 | }; 92 | }[]; 93 | // Timing metrics 94 | email_send_duration_seconds: { 95 | sum: number; 96 | count: number; 97 | avg: number; 98 | max: number; 99 | min: number; 100 | buckets: { 101 | '0.1': number; 102 | '0.5': number; 103 | '1': number; 104 | '2': number; 105 | '5': number; 106 | }; 107 | }; 108 | 109 | // Rate metrics 110 | email_send_rate: number; // Emails per second 111 | 112 | // Status metrics 113 | last_email_status: 'success' | 'failure' | 'none'; 114 | last_email_timestamp: number; 115 | 116 | // Connection metrics 117 | active_connections: number; 118 | connection_errors: number; 119 | 120 | // Rate limiting metrics 121 | rate_limit_exceeded_total: number; 122 | current_rate_limit_window: { 123 | count: number; 124 | remaining: number; 125 | reset_time: number; 126 | }; 127 | 128 | // Error metrics 129 | errors_by_type: { 130 | connection: number; 131 | authentication: number; 132 | rate_limit: number; 133 | validation: number; 134 | timeout: number; 135 | attachment: number; 136 | command: number; 137 | unknown: number; 138 | }; 139 | consecutive_failures: number; 140 | last_error_timestamp: number | null; 141 | banned_recipients_count: number; 142 | total_retry_attempts: number; 143 | successful_retries: number; 144 | failure_details: { 145 | last_error: { 146 | timestamp: Date; 147 | recipient: string; 148 | subject: string; 149 | error: { 150 | code: string; 151 | message: string; 152 | details: any; 153 | }; 154 | command: string; 155 | attempt: number; 156 | } | null; 157 | error_count_by_recipient: Map; 158 | most_common_errors: Array<{ 159 | type: string; 160 | count: number; 161 | }>; 162 | avg_failures_per_recipient: number; 163 | }; 164 | 165 | } -------------------------------------------------------------------------------- /src/constants/mime-types.ts: -------------------------------------------------------------------------------- 1 | export const mimeTypes: { [key: string]: string } = { 2 | // Application Types 3 | '.aac': 'audio/aac', 4 | '.abw': 'application/x-abiword', 5 | '.arc': 'application/x-freearc', 6 | '.azw': 'application/vnd.amazon.ebook', 7 | '.bin': 'application/octet-stream', 8 | '.bz': 'application/x-bzip', 9 | '.bz2': 'application/x-bzip2', 10 | '.cda': 'application/x-cdf', 11 | '.csh': 'application/x-csh', 12 | '.doc': 'application/msword', 13 | '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 14 | '.eot': 'application/vnd.ms-fontobject', 15 | '.epub': 'application/epub+zip', 16 | '.gz': 'application/gzip', 17 | '.jar': 'application/java-archive', 18 | '.json': 'application/json', 19 | '.jsonld': 'application/ld+json', 20 | '.mpkg': 'application/vnd.apple.installer+xml', 21 | '.odp': 'application/vnd.oasis.opendocument.presentation', 22 | '.ods': 'application/vnd.oasis.opendocument.spreadsheet', 23 | '.odt': 'application/vnd.oasis.opendocument.text', 24 | '.ogx': 'application/ogg', 25 | '.pdf': 'application/pdf', 26 | '.php': 'application/x-httpd-php', 27 | '.ppt': 'application/vnd.ms-powerpoint', 28 | '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 29 | '.rar': 'application/vnd.rar', 30 | '.rtf': 'application/rtf', 31 | '.sh': 'application/x-sh', 32 | '.tar': 'application/x-tar', 33 | '.vsd': 'application/vnd.visio', 34 | '.xhtml': 'application/xhtml+xml', 35 | '.xls': 'application/vnd.ms-excel', 36 | '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 37 | '.xml': 'application/xml', 38 | '.xul': 'application/vnd.mozilla.xul+xml', 39 | '.zip': 'application/zip', 40 | '.7z': 'application/x-7z-compressed', 41 | 42 | // Audio Types 43 | '.mid': 'audio/midi', 44 | '.midi': 'audio/midi', 45 | '.mp3': 'audio/mpeg', 46 | '.oga': 'audio/ogg', 47 | '.opus': 'audio/opus', 48 | '.wav': 'audio/wav', 49 | '.weba': 'audio/webm', 50 | '.3gp': 'audio/3gpp', 51 | '.3g2': 'audio/3gpp2', 52 | 53 | // Image Types 54 | '.apng': 'image/apng', 55 | '.avif': 'image/avif', 56 | '.bmp': 'image/bmp', 57 | '.gif': 'image/gif', 58 | '.ico': 'image/vnd.microsoft.icon', 59 | '.jpeg': 'image/jpeg', 60 | '.jpg': 'image/jpeg', 61 | '.png': 'image/png', 62 | '.svg': 'image/svg+xml', 63 | '.tif': 'image/tiff', 64 | '.tiff': 'image/tiff', 65 | '.webp': 'image/webp', 66 | 67 | // Text Types 68 | '.css': 'text/css', 69 | '.csv': 'text/csv', 70 | '.htm': 'text/html', 71 | '.html': 'text/html', 72 | '.ics': 'text/calendar', 73 | '.js': 'text/javascript', 74 | '.mjs': 'text/javascript', 75 | '.txt': 'text/plain', 76 | 77 | // Video Types 78 | '.avi': 'video/x-msvideo', 79 | '.mp4': 'video/mp4', 80 | '.mpeg': 'video/mpeg', 81 | '.ogv': 'video/ogg', 82 | '.ts': 'video/mp2t', 83 | '.webm': 'video/webm', 84 | '.wmv': 'video/x-ms-wmv', 85 | 86 | // Font Types 87 | '.otf': 'font/otf', 88 | '.ttf': 'font/ttf', 89 | '.woff': 'font/woff', 90 | '.woff2': 'font/woff2', 91 | 92 | // Model Types 93 | '.gltf': 'model/gltf+json', 94 | '.glb': 'model/gltf-binary', 95 | '.obj': 'model/obj', 96 | '.stl': 'model/stl', 97 | '.usdz': 'model/vnd.usdz+zip', 98 | 99 | // Chemical and Scientific Types 100 | '.cif': 'chemical/x-cif', 101 | '.mol': 'chemical/x-mdl-molfile', 102 | '.pdb': 'chemical/x-pdb', 103 | '.xyz': 'chemical/x-xyz', 104 | 105 | // CAD and Technical Types 106 | '.dwg': 'image/vnd.dwg', 107 | '.dxf': 'image/vnd.dxf', 108 | '.step': 'application/step', 109 | '.stp': 'application/step', 110 | '.iges': 'model/iges', 111 | '.igs': 'model/iges', 112 | 113 | // Database Types 114 | '.mdb': 'application/x-msaccess', 115 | '.sql': 'application/sql', 116 | '.sqlite': 'application/x-sqlite3', 117 | '.db': 'application/x-sqlite3', 118 | 119 | // Vector Graphics 120 | '.ai': 'application/postscript', 121 | '.eps': 'application/postscript', 122 | '.ps': 'application/postscript', 123 | 124 | // E-book Formats 125 | '.mobi': 'application/x-mobipocket-ebook', 126 | '.azw3': 'application/vnd.amazon.ebook', 127 | 128 | // Certificate and Security Types 129 | '.cer': 'application/x-x509-ca-cert', 130 | '.crt': 'application/x-x509-ca-cert', 131 | '.der': 'application/x-x509-ca-cert', 132 | '.p7b': 'application/x-pkcs7-certificates', 133 | '.p7c': 'application/x-pkcs7-mime', 134 | '.p7m': 'application/x-pkcs7-mime', 135 | '.p7s': 'application/x-pkcs7-signature', 136 | '.pem': 'application/x-pem-file', 137 | 138 | // Configuration and System Files 139 | '.cfg': 'text/plain', 140 | '.conf': 'text/plain', 141 | '.ini': 'text/plain', 142 | '.log': 'text/plain', 143 | '.prop': 'text/plain', 144 | '.properties': 'text/plain', 145 | '.yaml': 'text/yaml', 146 | '.yml': 'text/yaml', 147 | '.toml': 'text/plain', 148 | 149 | // Source Code Files 150 | '.c': 'text/x-c', 151 | '.cpp': 'text/x-c++', 152 | '.cs': 'text/x-csharp', 153 | '.go': 'text/x-go', 154 | '.java': 'text/x-java-source', 155 | '.py': 'text/x-python', 156 | '.rb': 'text/x-ruby', 157 | '.rs': 'text/x-rust', 158 | '.swift': 'text/x-swift', 159 | '.tsx': 'text/typescript-jsx', 160 | '.jsx': 'text/jsx' 161 | }; -------------------------------------------------------------------------------- /tests/logging.test.ts: -------------------------------------------------------------------------------- 1 | import { FastMailer } from '../src'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import dotenv from 'dotenv'; 5 | 6 | dotenv.config({path: path.join(__dirname, '../.env.test')}); 7 | 8 | describe('Logging Tests', () => { 9 | const logFile = path.join(__dirname, 'test-log.log'); 10 | let mailer: FastMailer; 11 | 12 | beforeEach(() => { 13 | // Clear log file before each test 14 | if (fs.existsSync(logFile)) { 15 | fs.unlinkSync(logFile); 16 | } 17 | 18 | mailer = new FastMailer({ 19 | host: process.env.SMTP_HOST || '', 20 | port: parseInt(process.env.SMTP_PORT || '587'), 21 | secure: true, 22 | auth: { 23 | user: process.env.EMAIL_USER || '', 24 | pass: process.env.EMAIL_PASS || '' 25 | }, 26 | from: process.env.FROM || '', 27 | logging: { 28 | level: 'debug', 29 | format: 'json', 30 | destination: logFile 31 | } 32 | }); 33 | }); 34 | 35 | afterEach(() => { 36 | // Clean up log file after tests 37 | if (fs.existsSync(logFile)) { 38 | fs.unlinkSync(logFile); 39 | } 40 | }); 41 | 42 | test('should create log file if it does not exist', () => { 43 | expect(fs.existsSync(logFile)).toBe(true); 44 | }); 45 | 46 | test('should log successful email send', async () => { 47 | const testEmail = { 48 | to: 'dev.mansouriyoussef@gmail.com', 49 | subject: 'Test Email', 50 | text: 'This is a test email' 51 | }; 52 | 53 | await mailer.sendMail(testEmail); 54 | 55 | // Wait for log to be written 56 | await new Promise(resolve => setTimeout(resolve, 100)); 57 | 58 | const logContent = fs.readFileSync(logFile, 'utf8'); 59 | const logEntries = logContent.trim().split('\n') 60 | .map(line => { 61 | try { 62 | return JSON.parse(line); 63 | } catch (e) { 64 | console.error('Failed to parse log line:', line); 65 | return null; 66 | } 67 | }) 68 | .filter(entry => entry !== null); 69 | 70 | // Changed filtering to look for any email-related entries 71 | const emailSendEntries = logEntries.filter(entry => 72 | entry.subject === testEmail.subject && 73 | entry.recipients === testEmail.to 74 | ); 75 | 76 | console.log("emailSendEntries", emailSendEntries) 77 | 78 | expect(emailSendEntries.length).toBeGreaterThan(0); 79 | expect(emailSendEntries[1]).toMatchObject({ 80 | success: true, 81 | recipients: testEmail.to, 82 | subject: testEmail.subject, 83 | level: 'info' 84 | }); 85 | expect(emailSendEntries[1].messageId).toBeDefined(); 86 | expect(emailSendEntries[1].sendTime).toBeDefined(); 87 | expect(emailSendEntries[1].timestamp).toBeDefined(); 88 | }, 15000); 89 | 90 | 91 | test('should respect log level configuration', async () => { 92 | const infoMailer = new FastMailer({ 93 | host: process.env.SMTP_HOST || '', 94 | port: parseInt(process.env.SMTP_PORT || '587'), 95 | secure: true, 96 | auth: { 97 | user: process.env.EMAIL_USER || '', 98 | pass: process.env.EMAIL_PASS || '' 99 | }, 100 | from: process.env.FROM || '', 101 | logging: { 102 | level: 'info', 103 | format: 'json', 104 | destination: logFile 105 | } 106 | }); 107 | 108 | const testEmail = { 109 | to: 'test@example.com', 110 | subject: 'Test Email', 111 | text: 'This is a test email' 112 | }; 113 | 114 | await infoMailer.sendMail(testEmail); 115 | 116 | // Wait longer for log to be written 117 | await new Promise(resolve => setTimeout(resolve, 1000)); 118 | 119 | const logContent = fs.readFileSync(logFile, 'utf8'); 120 | const logEntries = logContent.trim().split('\n') 121 | .map(line => { 122 | try { 123 | return JSON.parse(line); 124 | } catch (e) { 125 | console.error('Failed to parse log line:', line); 126 | return null; 127 | } 128 | }) 129 | .filter(entry => entry !== null); 130 | 131 | const debugEntries = logEntries.filter(entry => entry.level === 'debug'); 132 | expect(debugEntries.length).toBe(0); 133 | 134 | const infoEntries = logEntries.filter(entry => entry.level === 'info'); 135 | expect(infoEntries.length).toBeGreaterThan(0); 136 | }, 15000); 137 | 138 | test('should include custom fields in log entries', async () => { 139 | const customFieldsMailer = new FastMailer({ 140 | host: process.env.SMTP_HOST || '', 141 | port: parseInt(process.env.SMTP_PORT || '587'), 142 | secure: true, 143 | auth: { 144 | user: process.env.EMAIL_USER || '', 145 | pass: process.env.EMAIL_PASS || '' 146 | }, 147 | from: process.env.FROM || '', 148 | logging: { 149 | level: 'info', 150 | format: 'json', 151 | destination: logFile, 152 | customFields: ['messageId', 'sendTime'] 153 | } 154 | }); 155 | 156 | const testEmail = { 157 | to: 'test@example.com', 158 | subject: 'Test Email', 159 | text: 'This is a test email' 160 | }; 161 | 162 | await customFieldsMailer.sendMail(testEmail); 163 | 164 | // Wait longer for log to be written 165 | await new Promise(resolve => setTimeout(resolve, 1000)); 166 | 167 | const logContent = fs.readFileSync(logFile, 'utf8'); 168 | const logEntries = logContent.trim().split('\n') 169 | .map(line => { 170 | try { 171 | return JSON.parse(line); 172 | } catch (e) { 173 | console.error('Failed to parse log line:', line); 174 | return null; 175 | } 176 | }) 177 | .filter(entry => entry !== null); 178 | 179 | const successEntry = logEntries.find(entry => entry.event === 'mail_success'); 180 | expect(successEntry).toBeDefined(); 181 | expect(successEntry.messageId).toBeDefined(); 182 | expect(successEntry.sendTime).toBeDefined(); 183 | }, 15000); 184 | }); 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fast-Mailer 2 | 3 | High-performance, intelligent email delivery system powered by advanced technology. 4 | 5 | ## Features 6 | 7 | - 🚀 High-performance email sending with connection pooling 8 | - 🔒 TLS/SSL security with modern cipher support 9 | - 🛡️ Advanced rate limiting and spam protection 10 | - 📊 Detailed metrics and monitoring 11 | - 📎 Attachment support with MIME type detection 12 | - ♻️ Comprehensive error handling and retries 13 | - 📝 Logging with customizable formats and levels 14 | - 🔷 TypeScript support with full type definitions 15 | 16 | ## What's new in v2.0 17 | 18 | - ✨ Enhanced rate limiting with per-recipient tracking and rapid attempt detection 19 | - 🔒 Improved security with TLS 1.2/1.3 and modern cipher suites 20 | - 📊 Extended metrics with detailed failure tracking 21 | - 🛡️ Advanced spam protection with recipient banning 22 | - 📝 Enhanced logging with masking of sensitive data 23 | - ♻️ Improved error handling with detailed error context 24 | 25 | ### FastMailer vs NodeMailer 26 | 27 | - 🚀 Up to 5x faster email sending with connection pooling 28 | - 🔒 Modern TLS/SSL security with cipher suite control 29 | - 🛡️ Advanced rate limiting and spam protection 30 | - 📊 Comprehensive metrics and monitoring 31 | - 📎 Smart MIME type detection for attachments 32 | - ♻️ Intelligent error handling and retries 33 | - 📝 Structured logging with customizable formats 34 | - 🔷 Full TypeScript support with type definitions 35 | 36 | ## Installation 37 | 38 | ```bash 39 | npm install fast-mailer 40 | ``` 41 | 42 | ### Basic Example 43 | 44 | ```typescript 45 | import { FastMailer } from 'fast-mailer'; 46 | 47 | const mailer = new FastMailer({ 48 | host: 'smtp.example.com', 49 | port: 465, 50 | secure: true, 51 | auth: { 52 | user: 'user@example.com', 53 | pass: 'password' 54 | }, 55 | from: 'user@example.com' 56 | }); 57 | ``` 58 | 59 | ### Sending an Email 60 | 61 | ```typescript 62 | mailer.send({ 63 | to: 'recipient@example.com', 64 | subject: 'Hello, world!', 65 | text: 'This is a test email.' 66 | }); 67 | ``` 68 | 69 | ### Sending an Email with Attachments 70 | 71 | ```typescript 72 | mailer.send({ 73 | to: 'recipient@example.com', 74 | subject: 'Hello, world!', 75 | text: 'This is a test email.', 76 | attachments: [{ filename: 'example.txt', path: 'path/to/example.txt' }] 77 | }); 78 | ``` 79 | 80 | ### Using a Custom Logger 81 | 82 | ```typescript 83 | 84 | const mailer = new FastMailer({ 85 | host: 'smtp.example.com', 86 | port: 465, 87 | secure: true, 88 | from: 'user@example.com', 89 | auth: { 90 | user: 'user@example.com', 91 | pass: 'password' 92 | }, 93 | logging: { 94 | level: 'debug', // 'debug', 'info', 'warn', 'error' 95 | format: 'json', // 'json' or 'text' 96 | destination: 'logs/mailer.log', 97 | customFields: ['messageId', 'recipients'] // Additional fields to include 98 | } 99 | }); 100 | 101 | // Logs will be written to logs/mailer.log with entries like: 102 | // JSON format: 103 | { 104 | "timestamp": "2024-02-20T10:30:45.123Z", 105 | "level": "info", 106 | "event": "mail_success", 107 | "messageId": "abc123", 108 | "recipients": ["user@example.com"], 109 | "subject": "Test Email", 110 | "sendTime": 150 111 | } 112 | 113 | // Text format: 114 | // [2024-02-20T10:30:45.123Z] INFO: {"event":"mail_success","messageId":"abc123",...} 115 | 116 | ``` 117 | 118 | ### Using Metrics 119 | 120 | ```typescript 121 | 122 | const mailer = new FastMailer({ 123 | host: 'smtp.example.com', 124 | port: 465, 125 | secure: true, 126 | from: 'user@example.com', 127 | auth: { 128 | user: 'user@example.com', 129 | pass: 'password' 130 | } 131 | }); 132 | 133 | // Get current metrics 134 | const metrics = mailer.getMetrics(); 135 | 136 | console.log('Email Metrics:', { 137 | total: metrics.emails_total, 138 | successful: metrics.emails_successful, 139 | failed: metrics.emails_failed, 140 | avgSendTime: metrics.email_send_duration_seconds.avg, 141 | sendRate: metrics.email_send_rate, 142 | activeConnections: metrics.active_connections, 143 | errorsByType: metrics.errors_by_type 144 | }); 145 | 146 | ``` 147 | 148 | ### Rate Limiting 149 | 150 | ```typescript 151 | 152 | const mailer = new FastMailer({ 153 | host: 'smtp.example.com', 154 | port: 465, 155 | secure: true, 156 | from: 'user@example.com', 157 | auth: { 158 | user: 'user@example.com', 159 | pass: 'password' 160 | }, 161 | rateLimiting: { 162 | perRecipient: true, 163 | burstLimit: 5, 164 | cooldownPeriod: 1000, 165 | banDuration: 7200000, 166 | maxConsecutiveFailures: 3, 167 | failureCooldown: 300000 168 | } 169 | }); 170 | 171 | 172 | ``` 173 | 174 | ### Configuration Options 175 | 176 | | Option | Type | Default | Description | 177 | |--------|------|---------|-------------| 178 | | host | string | - | SMTP server hostname (required) | 179 | | port | number | - | SMTP server port (required) | 180 | | secure | boolean | false | Whether to use TLS/SSL connection | 181 | | auth | object | - | Authentication credentials (required) | 182 | | auth.user | string | - | SMTP username | 183 | | auth.pass | string | - | SMTP password | 184 | | from | string | - | Default sender email address (required) | 185 | | retryAttempts | number | 3 | Number of retry attempts for failed sends | 186 | | timeout | number | 5000 | Socket timeout in milliseconds | 187 | | keepAlive | boolean | false | Keep connection alive between sends | 188 | | poolSize | number | 5 | Maximum number of simultaneous connections | 189 | | rateLimiting | object | - | Rate limiting configuration | 190 | | rateLimiting.perRecipient | boolean | true | Apply limits per recipient | 191 | | rateLimiting.burstLimit | number | 5 | Maximum emails per cooldown period | 192 | | rateLimiting.cooldownPeriod | number | 1000 | Cooldown period in milliseconds | 193 | | rateLimiting.banDuration | number | 7200000 | Ban duration in milliseconds (2 hours) | 194 | | rateLimiting.maxConsecutiveFailures | number | 3 | Max failures before temp ban | 195 | | rateLimiting.failureCooldown | number | 300000 | Failure cooldown in milliseconds (5 min) | 196 | | rateLimiting.maxRapidAttempts | number | 5 | Max rapid attempts before temp ban | 197 | | rateLimiting.rapidPeriod | number | 10000 | Rapid period in milliseconds (10 seconds) | 198 | | logging | object | - | Logging configuration | 199 | | logging.level | string | 'info' | Log level ('debug','info','warn','error') | 200 | | logging.format | string | 'json' | Log format ('json' or 'text') | 201 | | logging.customFields | string[] | [] | Additional fields to include in logs | 202 | | logging.destination | string | - | Log file path | 203 | 204 | 205 | ### License 206 | 207 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 208 | -------------------------------------------------------------------------------- /tests/ratelimit.test.ts: -------------------------------------------------------------------------------- 1 | import { FastMailer, MailerConfig } from '../src'; 2 | import dotenv from 'dotenv'; 3 | import path from 'path'; 4 | 5 | dotenv.config({ path: path.join(__dirname, '../.env.test') }); 6 | 7 | describe('Rate Limiting Tests', () => { 8 | let mailer: FastMailer; 9 | 10 | beforeAll(() => { 11 | jest.useFakeTimers({ doNotFake: ['performance'] }); 12 | jest.setTimeout(10000); // Increase timeout to 10 seconds 13 | }); 14 | 15 | afterAll(() => { 16 | jest.useRealTimers(); 17 | }); 18 | 19 | beforeEach(() => { 20 | const config: MailerConfig = { 21 | host: process.env.SMTP_HOST || '', 22 | port: parseInt(process.env.SMTP_PORT || '465'), 23 | secure: true, 24 | auth: { 25 | user: process.env.EMAIL_USER || '', 26 | pass: process.env.EMAIL_PASS || '' 27 | }, 28 | from: process.env.FROM || '', 29 | rateLimiting: { 30 | perRecipient: true, 31 | burstLimit: 2, 32 | cooldownPeriod: 1000, // 1 second cooldown 33 | banDuration: 7200000, // 2 hour ban 34 | maxConsecutiveFailures: 3, 35 | failureCooldown: 300000, // 5 min failure cooldown 36 | maxRapidAttempts: 5, 37 | rapidPeriod: 10000 // 10 second period 38 | }, 39 | logging: { 40 | level: 'debug', 41 | format: 'text', 42 | destination: "./ratelimit.log" 43 | } 44 | }; 45 | mailer = new FastMailer(config); 46 | }); 47 | 48 | it('should enforce burst limit per recipient', async () => { 49 | const recipient = process.env.TO || ''; 50 | const mailOptions = { 51 | to: recipient, 52 | subject: 'Test Email', 53 | text: 'Test content' 54 | }; 55 | 56 | // Mock the internal rate limit tracking 57 | const mockLimits = { 58 | count: 2, // Already at burst limit 59 | lastReset: Date.now(), 60 | banned: false, 61 | banExpiry: Date.now() + 7200000, 62 | consecutiveFailures: 0, 63 | lastFailure: Date.now(), 64 | rapidAttempts: 0, 65 | lastAttempt: Date.now() 66 | }; 67 | mailer['rateLimits'].set(recipient, mockLimits); 68 | 69 | // Should fail due to burst limit 70 | await expect(mailer.sendMail(mailOptions)).rejects.toMatchObject({ 71 | code: 'ERATELIMIT', 72 | message: 'Rate limit exceeded for recipient', 73 | details: { 74 | type: 'rate_limit_error', 75 | context: { 76 | recipient, 77 | burstLimit: 2, 78 | cooldownPeriod: 1000 79 | } 80 | } 81 | }); 82 | }); 83 | 84 | it('should reset rate limits after cooldown period', async () => { 85 | const recipient = process.env.TO || ''; 86 | const mailOptions = { 87 | to: recipient, 88 | subject: 'Test Email', 89 | text: 'Test content' 90 | }; 91 | 92 | // Mock initial rate limit state 93 | const mockLimits = { 94 | count: 2, 95 | lastReset: Date.now() - 1100, // Just over cooldown period ago 96 | banned: false, 97 | banExpiry: Date.now() + 7200000, 98 | consecutiveFailures: 0, 99 | lastFailure: Date.now(), 100 | rapidAttempts: 0, 101 | lastAttempt: Date.now() 102 | }; 103 | mailer['rateLimits'].set(recipient, mockLimits); 104 | 105 | // Should be able to send after cooldown 106 | await expect(mailer.sendMail(mailOptions)).resolves.toMatchObject({ success: true }); 107 | }, 10000); // Increase timeout to 10 seconds 108 | 109 | it('should handle consecutive failures and temporary bans', async () => { 110 | const recipient = process.env.TO || ''; 111 | const mailOptions = { 112 | to: recipient, 113 | subject: 'Test Email', 114 | text: 'Test content' 115 | }; 116 | 117 | // Mock banned state 118 | const banExpiry = Date.now() + 7200000; 119 | const mockLimits = { 120 | count: 0, 121 | lastReset: Date.now(), 122 | banned: true, 123 | banExpiry, 124 | consecutiveFailures: 3, 125 | lastFailure: Date.now(), 126 | rapidAttempts: 0, 127 | lastAttempt: Date.now() 128 | }; 129 | mailer['rateLimits'].set(recipient, mockLimits); 130 | 131 | // Attempt should fail due to ban 132 | await expect(mailer.sendMail(mailOptions)).rejects.toMatchObject({ 133 | code: 'ERATELIMIT', 134 | message: 'Recipient is temporarily banned due to rate limit violations or consecutive failures', 135 | details: { 136 | type: 'rate_limit_error', 137 | context: { 138 | recipient, 139 | consecutiveFailures: 3, 140 | banExpiry: expect.any(String) 141 | } 142 | } 143 | }); 144 | 145 | // Clear the rate limits 146 | mailer['rateLimits'].delete(recipient); 147 | 148 | // Should be able to send again after clearing limits 149 | await expect(mailer.sendMail(mailOptions)).resolves.toMatchObject({ success: true }); 150 | }, 10000); // Increase timeout to 10 seconds 151 | 152 | it('should track rate limit metrics', async () => { 153 | const recipient = process.env.TO || ''; 154 | const mailOptions = { 155 | to: recipient, 156 | subject: 'Test Email', 157 | text: 'Test content' 158 | }; 159 | 160 | // Mock rate limit exceeded state 161 | const mockLimits = { 162 | count: 2, 163 | lastReset: Date.now(), 164 | banned: false, 165 | banExpiry: Date.now() + 7200000, 166 | consecutiveFailures: 0, 167 | lastFailure: Date.now(), 168 | rapidAttempts: 0, 169 | lastAttempt: Date.now() 170 | }; 171 | mailer['rateLimits'].set(recipient, mockLimits); 172 | 173 | try { 174 | await mailer.sendMail(mailOptions); 175 | } catch (error) { 176 | // Expected error 177 | } 178 | 179 | const metrics = mailer.getMetrics(); 180 | expect(metrics.rate_limit_exceeded_total).toBeGreaterThan(0); 181 | expect(metrics.current_rate_limit_window).toBeDefined(); 182 | expect(metrics.current_rate_limit_window.count).toBeDefined(); 183 | expect(metrics.current_rate_limit_window.remaining).toBeDefined(); 184 | expect(metrics.current_rate_limit_window.reset_time).toBeDefined(); 185 | expect(metrics.errors_by_type.rate_limit).toBeGreaterThan(0); 186 | }); 187 | 188 | it('should enforce rapid attempt limits', async () => { 189 | const recipient = process.env.TO || ''; 190 | const mailOptions = { 191 | to: recipient, 192 | subject: 'Test Email', 193 | text: 'Test content' 194 | }; 195 | 196 | // Mock state with max rapid attempts 197 | const mockLimits = { 198 | count: 0, 199 | lastReset: Date.now(), 200 | banned: false, 201 | banExpiry: Date.now() + 7200000, 202 | consecutiveFailures: 0, 203 | lastFailure: Date.now(), 204 | rapidAttempts: 5, // At max rapid attempts 205 | lastAttempt: Date.now() 206 | }; 207 | mailer['rateLimits'].set(recipient, mockLimits); 208 | 209 | // Should fail due to rapid attempts 210 | await expect(mailer.sendMail(mailOptions)).rejects.toMatchObject({ 211 | code: 'ERATELIMIT', 212 | message: 'Too many rapid sending attempts', 213 | details: { 214 | type: 'rate_limit_error', 215 | context: { 216 | recipient 217 | } 218 | } 219 | }); 220 | 221 | // Mock state with rapid attempts but outside rapid period 222 | mockLimits.lastAttempt = Date.now() - 11000; // Just outside rapid period 223 | mockLimits.banned = false; // Ensure not banned 224 | mockLimits.rapidAttempts = 0; // Reset rapid attempts 225 | mailer['rateLimits'].set(recipient, mockLimits); 226 | 227 | // Should succeed since outside rapid period 228 | await expect(mailer.sendMail(mailOptions)).resolves.toMatchObject({ success: true }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /tests/mailer.test.ts: -------------------------------------------------------------------------------- 1 | import { FastMailer } from '../src'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import dotenv from 'dotenv'; 5 | import { Socket } from 'net'; 6 | import { TLSSocket } from 'tls'; 7 | import { EventEmitter } from 'events'; 8 | 9 | dotenv.config({path: path.join(__dirname, '../.env.test')}); 10 | 11 | describe('FastMailer', () => { 12 | let mailer: FastMailer; 13 | 14 | beforeEach(() => { 15 | mailer = new FastMailer({ 16 | host: process.env.SMTP_HOST || '', 17 | port: parseInt(process.env.SMTP_PORT || '465'), 18 | from: process.env.FROM || '', 19 | auth: { 20 | user: process.env.EMAIL_USER || '', 21 | pass: process.env.EMAIL_PASS || '' 22 | }, 23 | secure: true, 24 | rateLimiting: { 25 | perRecipient: true, 26 | burstLimit: 5, 27 | cooldownPeriod: 1000, 28 | banDuration: 7200000, 29 | maxConsecutiveFailures: 3, 30 | failureCooldown: 300000 31 | }, 32 | logging: { 33 | level: 'debug', 34 | format: 'json', 35 | destination: "./mailer.log" 36 | } 37 | }); 38 | }); 39 | 40 | describe('sendMail', () => { 41 | it('should send an email successfully', async () => { 42 | const result = await mailer.sendMail({ 43 | to: process.env.TO || '', 44 | subject: 'Test Email', 45 | text: 'This is a test email' 46 | }); 47 | 48 | expect(result.success).toBe(true); 49 | expect(result.messageId).toBeDefined(); 50 | expect(result.timestamp).toBeInstanceOf(Date); 51 | expect(result.recipients).toBeDefined(); 52 | }, 15000); // Increased timeout to 15 seconds 53 | 54 | it('should handle multiple recipients', async () => { 55 | const result = await mailer.sendMail({ 56 | to: [process.env.TO || '', process.env.TO_2 || ''], 57 | subject: 'Test Email', 58 | text: 'This is a test email' 59 | }); 60 | 61 | expect(result.success).toBe(true); 62 | expect(result.recipients).toContain(process.env.TO); 63 | expect(result.recipients).toContain(process.env.TO_2); 64 | }, 15000); 65 | 66 | it('should send HTML content', async () => { 67 | const result = await mailer.sendMail({ 68 | to: process.env.TO || '', 69 | subject: 'HTML Test', 70 | html: '

Hello

This is HTML content

' 71 | }); 72 | 73 | expect(result.success).toBe(true); 74 | }, 15000); 75 | 76 | it('should handle CC and BCC recipients', async () => { 77 | const result = await mailer.sendMail({ 78 | to: process.env.TO || '', 79 | subject: 'CC/BCC Test', 80 | text: 'Testing CC and BCC', 81 | cc: process.env.TO_2 || '', 82 | bcc: process.env.EMAIL_USER || '' 83 | }); 84 | 85 | expect(result.success).toBe(true); 86 | expect(result.recipients).toContain(process.env.TO); 87 | expect(result.recipients).toContain(process.env.TO_2); 88 | expect(result.recipients).toContain(process.env.EMAIL_USER); 89 | }, 15000); 90 | 91 | it('should handle attachments', async () => { 92 | const result = await mailer.sendMail({ 93 | to: process.env.TO || '', 94 | subject: 'Attachment Test', 95 | text: 'Testing attachments', 96 | attachments: [{ 97 | filename: 'test.txt', 98 | content: 'Hello World' 99 | }] 100 | }); 101 | 102 | expect(result.success).toBe(true); 103 | }, 15000); 104 | 105 | it('should handle file path attachments', async () => { 106 | // Create a temporary test file 107 | fs.writeFileSync('test.txt', 'Test content'); 108 | 109 | const result = await mailer.sendMail({ 110 | to: process.env.TO || '', 111 | subject: 'File Attachment Test', 112 | text: 'Testing file attachments', 113 | attachments: [{ 114 | path: './test.txt' 115 | }] 116 | }); 117 | 118 | // Clean up 119 | fs.unlinkSync('test.txt'); 120 | 121 | expect(result.success).toBe(true); 122 | }, 15000); 123 | 124 | it('should handle custom headers', async () => { 125 | const result = await mailer.sendMail({ 126 | to: process.env.TO || '', 127 | subject: 'Headers Test', 128 | text: 'Testing custom headers', 129 | headers: { 130 | 'X-Custom-Header': 'test-value' 131 | } 132 | }); 133 | 134 | expect(result.success).toBe(true); 135 | }, 15000); 136 | }); 137 | 138 | describe('verifyConnection', () => { 139 | it('should verify connection successfully', async () => { 140 | const isConnected = await mailer.verifyConnection(); 141 | expect(isConnected).toBe(true); 142 | }, 15000); 143 | 144 | it('should handle failed connections', async () => { 145 | const badMailer = new FastMailer({ 146 | host: 'invalid.host', 147 | port: 465, 148 | from: 'test@test.com', 149 | auth: { 150 | user: 'test', 151 | pass: 'test' 152 | }, 153 | logging: { 154 | level: 'debug', 155 | format: 'json' 156 | } 157 | }); 158 | 159 | const isConnected = await badMailer.verifyConnection(); 160 | expect(isConnected).toBe(false); 161 | }, 15000); 162 | }); 163 | 164 | describe('getMetrics', () => { 165 | it('should track email metrics', async () => { 166 | await mailer.sendMail({ 167 | to: process.env.TO || '', 168 | subject: 'Metrics Test', 169 | text: 'Testing metrics', 170 | }); 171 | 172 | const metrics = mailer.getMetrics(); 173 | 174 | expect(metrics.emails_total).toBe(1); 175 | expect(metrics.emails_successful).toBe(1); 176 | expect(metrics.emails_failed).toBe(0); 177 | expect(metrics.email_send_duration_seconds.avg).toBeGreaterThan(0); 178 | expect(metrics.email_send_duration_seconds.min).toBe(metrics.email_send_duration_seconds.max); 179 | expect(metrics.email_send_duration_seconds.buckets['0.1']).toBeGreaterThanOrEqual(0); 180 | expect(metrics.email_send_duration_seconds.buckets['5']).toBeGreaterThanOrEqual(0); 181 | expect(metrics.email_send_rate).toBeGreaterThanOrEqual(0); 182 | expect(metrics.last_email_timestamp).toBeDefined(); 183 | expect(metrics.last_email_status).toBe('success'); 184 | expect(metrics.active_connections).toBeDefined(); 185 | expect(metrics.connection_errors).toBeDefined(); 186 | expect(metrics.rate_limit_exceeded_total).toBeDefined(); 187 | expect(metrics.current_rate_limit_window).toBeDefined(); 188 | expect(metrics.errors_by_type).toBeDefined(); 189 | expect(metrics.consecutive_failures).toBeDefined(); 190 | expect(metrics.banned_recipients_count).toBeDefined(); 191 | expect(metrics.total_retry_attempts).toBeDefined(); 192 | expect(metrics.successful_retries).toBeDefined(); 193 | }, 15000); 194 | 195 | it('should track connection errors', async () => { 196 | const badMailer = new FastMailer({ 197 | host: 'invalid.host', 198 | port: 465, 199 | from: 'test@test.com', 200 | auth: { 201 | user: 'test', 202 | pass: 'test' 203 | }, 204 | logging: { 205 | level: 'debug', 206 | format: 'json' 207 | } 208 | }); 209 | 210 | try { 211 | await badMailer.sendMail({ 212 | to: 'test@test.com', 213 | subject: 'Failed Test', 214 | text: 'Testing failed metrics' 215 | }); 216 | expect(false).toBe(true); // Force test to fail if no error thrown 217 | } catch (error: any) { 218 | expect(error.code).toBe('ECONNECTION'); 219 | expect(error.message).toBe('SMTP connection failed, cannot send email'); 220 | expect(error.details.type).toBe('connection_error'); 221 | expect(error.details.context.host).toBe('invalid.host'); 222 | expect(error.details.context.port).toBe(465); 223 | expect(error.details.timestamp).toBeDefined(); 224 | } 225 | 226 | const metrics = badMailer.getMetrics(); 227 | console.log("Metrics: ", metrics); 228 | expect(metrics.emails_total).toBe(0); 229 | expect(metrics.emails_failed).toBe(0); 230 | expect(metrics.emails_successful).toBe(0); 231 | expect(metrics.last_email_status).toBe('failure'); 232 | expect(metrics.connection_errors).toBe(1); 233 | expect(metrics.errors_by_type.connection).toBeGreaterThan(1); 234 | }, 15000); 235 | 236 | it('should track invalid sender errors', async () => { 237 | const mailer = new FastMailer({ 238 | host: process.env.SMTP_HOST || '', 239 | port: parseInt(process.env.SMTP_PORT || '465'), 240 | from: 'invalid', 241 | auth: { 242 | user: process.env.EMAIL_USER || '', 243 | pass: process.env.EMAIL_PASS || '' 244 | }, 245 | }); 246 | 247 | try { 248 | await mailer.sendMail({ 249 | to: 'invalid-mail', 250 | subject: 'Failed Test', 251 | text: 'Testing failed metrics' 252 | }); 253 | expect(false).toBe(true); // Force test to fail if no error thrown 254 | } catch (error: any) { 255 | expect(error.code).toBe('EINVALIDEMAIL'); 256 | expect(error.details.type).toBe('validation_error'); 257 | } 258 | 259 | const metrics = mailer.getMetrics(); 260 | console.log("Metrics 2: ", metrics); 261 | expect(metrics.emails_total).toBe(0); 262 | expect(metrics.emails_failed).toBe(0); 263 | expect(metrics.active_connections).toBe(1); 264 | expect(metrics.email_send_duration_seconds.count).toBe(0); 265 | expect(metrics.current_rate_limit_window.remaining).toBeDefined(); 266 | expect(metrics.current_rate_limit_window.reset_time).toBeGreaterThan(Date.now()); 267 | expect(metrics.consecutive_failures).toBe(0); 268 | }, 15000); 269 | }); 270 | 271 | it('should reject invalid email addresses', async () => { 272 | const invalidEmail = "notanemail" 273 | 274 | 275 | try { 276 | await mailer.sendMail({ 277 | to: invalidEmail, 278 | subject: 'Test Email', 279 | text: 'Test content' 280 | }); 281 | expect(false).toBe(true); // Force test to fail if no error thrown 282 | } catch (error: any) { 283 | expect(error.code).toBe('EINVALIDEMAIL'); 284 | expect(error.message).toBe(`Invalid email format: ${invalidEmail}`); 285 | expect(error.details).toMatchObject({ 286 | type: 'validation_error', 287 | context: { recipient: invalidEmail }, 288 | timestamp: expect.any(String) 289 | }); 290 | } 291 | 292 | const metrics = mailer.getMetrics(); 293 | console.log("Metrics: ", metrics); 294 | expect(metrics.emails_failed).toBe(0); 295 | /* expect(metrics.errors_by_type.validation).toBe(invalidEmails.length); */ 296 | expect(metrics.consecutive_failures).toBe(0); 297 | }, 15000); 298 | 299 | describe('Configuration', () => { 300 | it('should use default values for optional config', () => { 301 | const mailer = new FastMailer({ 302 | host: process.env.SMTP_HOST || '', 303 | port: parseInt(process.env.SMTP_PORT || '465'), 304 | secure: true, 305 | auth: { 306 | user: process.env.EMAIL_USER || '', 307 | pass: process.env.EMAIL_PASS || '' 308 | }, 309 | from: process.env.FROM || '' 310 | }); 311 | 312 | // @ts-ignore - Accessing private config for testing 313 | const config = mailer['config']; 314 | 315 | expect(config.retryAttempts).toBe(3); 316 | expect(config.timeout).toBe(5000); 317 | expect(config.keepAlive).toBe(false); 318 | expect(config.poolSize).toBe(5); 319 | expect(config.rateLimiting?.burstLimit).toBe(5); 320 | expect(config.rateLimiting?.cooldownPeriod).toBe(1000); 321 | expect(config.rateLimiting?.banDuration).toBe(7200000); 322 | expect(config.rateLimiting?.maxConsecutiveFailures).toBe(3); 323 | expect(config.rateLimiting?.failureCooldown).toBe(300000); 324 | }, 15000); 325 | 326 | it('should override default values with provided config', () => { 327 | const mailer = new FastMailer({ 328 | host: process.env.SMTP_HOST || '', 329 | port: parseInt(process.env.SMTP_PORT || '465'), 330 | secure: true, 331 | auth: { 332 | user: process.env.EMAIL_USER || '', 333 | pass: process.env.EMAIL_PASS || '' 334 | }, 335 | from: process.env.FROM || '', 336 | retryAttempts: 5, 337 | timeout: 10000, 338 | keepAlive: true, 339 | poolSize: 10, 340 | rateLimiting: { 341 | perRecipient: true, 342 | burstLimit: 10, 343 | cooldownPeriod: 2000, 344 | banDuration: 3600000, 345 | maxConsecutiveFailures: 5, 346 | failureCooldown: 600000 347 | } 348 | }); 349 | 350 | // @ts-ignore - Accessing private config for testing 351 | const config = mailer['config']; 352 | 353 | expect(config.retryAttempts).toBe(5); 354 | expect(config.timeout).toBe(10000); 355 | expect(config.keepAlive).toBe(true); 356 | expect(config.poolSize).toBe(10); 357 | expect(config.rateLimiting?.burstLimit).toBe(10); 358 | expect(config.rateLimiting?.cooldownPeriod).toBe(2000); 359 | expect(config.rateLimiting?.banDuration).toBe(3600000); 360 | expect(config.rateLimiting?.maxConsecutiveFailures).toBe(5); 361 | expect(config.rateLimiting?.failureCooldown).toBe(600000); 362 | }, 15000); 363 | 364 | it('should throw error if from address is missing', () => { 365 | expect(() => new FastMailer({ 366 | host: process.env.SMTP_HOST || '', 367 | port: parseInt(process.env.SMTP_PORT || '465'), 368 | auth: { 369 | user: 'test', 370 | pass: 'test' 371 | } 372 | } as any)).toThrow('From address is required in config'); 373 | }, 15000); 374 | }); 375 | }); 376 | -------------------------------------------------------------------------------- /src/mailer/mailer.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'net'; 2 | import { TLSSocket } from 'tls'; 3 | import { EventEmitter } from 'events'; 4 | import * as fs from 'fs'; 5 | import * as crypto from 'crypto'; 6 | import * as path from 'path'; 7 | import { MailerConfig, Metrics, MailOptions, SendResult, mimeTypes } from '../'; 8 | 9 | class FastMailer extends EventEmitter { 10 | 11 | private config: MailerConfig; 12 | private socket: Socket | TLSSocket | null; 13 | private connectionPool: Map; 14 | private metrics: Metrics; 15 | private logFilePath: string | undefined; 16 | private rateLimits: Map; 26 | 27 | constructor(config: MailerConfig) { 28 | super(); 29 | // Determine if port requires secure connection 30 | const securePort = config.port === 465; 31 | 32 | this.config = { 33 | ...config, 34 | // Number of retry attempts for failed email sends 35 | retryAttempts: config.retryAttempts || 3, 36 | // Socket timeout in milliseconds 37 | timeout: config.timeout || 5000, 38 | // Whether to keep connections alive between sends 39 | keepAlive: config.keepAlive || false, 40 | // Maximum number of simultaneous connections 41 | poolSize: config.poolSize || 5, 42 | // Force secure true if using secure port 43 | secure: securePort ? true : config.secure, 44 | // Rate limiting configuration with more secure defaults 45 | rateLimiting: { 46 | perRecipient: true, 47 | burstLimit: 5, // Lower burst limit 48 | cooldownPeriod: 1000, // 1 second cooldown 49 | banDuration: 7200000, // 2 hours 50 | maxConsecutiveFailures: 3, // Max failures before temp ban 51 | failureCooldown: 300000, // 5 min failure cooldown 52 | maxRapidAttempts: 10, // Max attempts within rapid period 53 | rapidPeriod: 10000, // 10 second rapid period 54 | ...config.rateLimiting 55 | }, 56 | // Logging configuration 57 | logging: { 58 | level: config.logging?.level || 'info', 59 | format: config.logging?.format || 'json', 60 | customFields: config.logging?.customFields || [], 61 | destination: config.logging?.destination 62 | } 63 | }; 64 | 65 | if (!this.config.from) { 66 | throw new Error('From address is required in config'); 67 | } 68 | 69 | // Log warning if secure port but secure not set 70 | if (securePort && !config.secure) { 71 | console.warn('Port 465 requires secure connection. Forcing secure: true'); 72 | } 73 | 74 | // Setup logging 75 | if (this.config.logging?.destination) { 76 | try { 77 | this.logFilePath = path.isAbsolute(this.config.logging.destination) ? 78 | this.config.logging.destination : 79 | path.join(process.cwd(), this.config.logging.destination); 80 | 81 | const logDir = path.dirname(this.logFilePath); 82 | if (!fs.existsSync(logDir)) { 83 | fs.mkdirSync(logDir, { recursive: true }); 84 | } 85 | 86 | fs.appendFileSync(this.logFilePath, ''); 87 | } catch (error) { 88 | this.logFilePath = undefined; 89 | console.warn('Failed to setup log file:', error); 90 | } 91 | } 92 | 93 | this.connectionPool = new Map(); 94 | this.rateLimits = new Map(); 95 | this.metrics = { 96 | emails_total: 0, 97 | emails_successful: 0, 98 | emails_failed: 0, 99 | failed_emails: [], // Add array to store failed emails 100 | email_send_duration_seconds: { 101 | sum: 0, 102 | count: 0, 103 | avg: 0, 104 | max: 0, 105 | min: Number.MAX_VALUE, 106 | buckets: { 107 | '0.1': 0, 108 | '0.5': 0, 109 | '1': 0, 110 | '2': 0, 111 | '5': 0 112 | } 113 | }, 114 | email_send_rate: 0, 115 | last_email_status: 'none', 116 | last_email_timestamp: Date.now(), 117 | active_connections: 0, 118 | connection_errors: 0, 119 | rate_limit_exceeded_total: 0, 120 | current_rate_limit_window: { 121 | count: 0, 122 | remaining: this.config.rateLimiting?.burstLimit || 5, 123 | reset_time: Date.now() + (this.config.rateLimiting?.cooldownPeriod || 1000) 124 | }, 125 | errors_by_type: { 126 | connection: 0, 127 | authentication: 0, 128 | rate_limit: 0, 129 | validation: 0, 130 | timeout: 0, 131 | attachment: 0, 132 | command: 0, 133 | unknown: 0 134 | }, 135 | consecutive_failures: 0, 136 | last_error_timestamp: null, 137 | banned_recipients_count: 0, 138 | total_retry_attempts: 0, 139 | successful_retries: 0, 140 | failure_details: { 141 | last_error: null, 142 | error_count_by_recipient: new Map(), 143 | most_common_errors: [], 144 | avg_failures_per_recipient: 0 145 | } 146 | }; 147 | this.socket = null; 148 | } 149 | 150 | private validateEmail(email: string): boolean { 151 | // More strict email validation regex 152 | const emailRegex = /^[a-zA-Z0-9](?:[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9!#$%&'*+\-/=?^_`{|}~]*[a-zA-Z0-9])?)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/; 153 | 154 | // Basic checks first 155 | if (!email || email.includes(' ') || email.includes('..') || email.startsWith('.') || email.endsWith('.') || email.includes('@@')) { 156 | return false; 157 | } 158 | 159 | return emailRegex.test(email); 160 | } 161 | 162 | private checkRateLimit(recipient: string): void { 163 | const now = Date.now(); 164 | let recipientLimits = this.rateLimits.get(recipient); 165 | 166 | if (!recipientLimits) { 167 | recipientLimits = { 168 | count: 0, 169 | lastReset: now, 170 | banned: false, 171 | banExpiry: 0, 172 | consecutiveFailures: 0, 173 | lastFailure: 0, 174 | rapidAttempts: 0, 175 | lastAttempt: now 176 | }; 177 | this.rateLimits.set(recipient, recipientLimits); 178 | } 179 | 180 | // Check rapid sending attempts 181 | const rapidPeriod = this.config.rateLimiting?.rapidPeriod || 10000; // 10 seconds 182 | if (now - recipientLimits.lastAttempt < rapidPeriod) { 183 | recipientLimits.rapidAttempts++; 184 | if (recipientLimits.rapidAttempts >= (this.config.rateLimiting?.maxRapidAttempts || 10)) { 185 | recipientLimits.banned = true; 186 | recipientLimits.banExpiry = now + (this.config.rateLimiting?.banDuration || 7200000); 187 | this.metrics.banned_recipients_count++; 188 | this.writeLog('debug', { 189 | recipient, 190 | event: 'banned', 191 | reason: 'rapid_attempts', 192 | attempts: recipientLimits.rapidAttempts, 193 | period: rapidPeriod, 194 | message: 'Too many rapid sending attempts' 195 | }); 196 | throw { 197 | code: 'ERATELIMIT', 198 | message: 'Too many rapid sending attempts', 199 | details: { 200 | type: 'rate_limit_error', 201 | context: { 202 | recipient, 203 | attempts: recipientLimits.rapidAttempts, 204 | period: rapidPeriod 205 | }, 206 | timestamp: new Date().toISOString() 207 | } 208 | }; 209 | } 210 | } else { 211 | recipientLimits.rapidAttempts = 1; 212 | } 213 | recipientLimits.lastAttempt = now; 214 | 215 | // Check if currently banned 216 | if (recipientLimits.banned) { 217 | if (now < recipientLimits.banExpiry) { 218 | this.metrics.last_email_status = 'failure'; 219 | this.metrics.errors_by_type.rate_limit++; 220 | this.writeLog('debug', { 221 | recipient, 222 | event: 'banned', 223 | banExpiry: new Date(recipientLimits.banExpiry).toISOString(), 224 | consecutiveFailures: recipientLimits.consecutiveFailures, 225 | message: 'Recipient is temporarily banned due to rate limit violations or consecutive failures' 226 | }); 227 | throw { 228 | code: 'ERATELIMIT', 229 | message: 'Recipient is temporarily banned due to rate limit violations or consecutive failures', 230 | details: { 231 | type: 'rate_limit_error', 232 | context: { 233 | recipient, 234 | banExpiry: new Date(recipientLimits.banExpiry).toISOString(), 235 | consecutiveFailures: recipientLimits.consecutiveFailures 236 | }, 237 | timestamp: new Date().toISOString() 238 | } 239 | }; 240 | } else { 241 | // Ban expired, reset all limits 242 | recipientLimits.banned = false; 243 | recipientLimits.count = 0; 244 | recipientLimits.lastReset = now; 245 | recipientLimits.consecutiveFailures = 0; 246 | recipientLimits.rapidAttempts = 0; 247 | this.metrics.banned_recipients_count--; 248 | } 249 | } 250 | 251 | // Check consecutive failures 252 | if (recipientLimits.consecutiveFailures >= (this.config.rateLimiting?.maxConsecutiveFailures ?? 3)) { 253 | const failureCooldown = this.config.rateLimiting?.failureCooldown ?? 300000; 254 | if (now - recipientLimits.lastFailure < failureCooldown) { 255 | recipientLimits.banned = true; 256 | recipientLimits.banExpiry = now + (this.config.rateLimiting?.banDuration ?? 7200000); 257 | this.metrics.last_email_status = 'failure'; 258 | this.metrics.errors_by_type.rate_limit++; 259 | this.metrics.banned_recipients_count++; 260 | this.writeLog('debug', { 261 | recipient, 262 | event: 'banned', 263 | consecutiveFailures: recipientLimits.consecutiveFailures, 264 | failureCooldown, 265 | message: 'Too many consecutive failures for recipient' 266 | }); 267 | throw { 268 | code: 'ERATELIMIT', 269 | message: 'Too many consecutive failures for recipient', 270 | details: { 271 | type: 'rate_limit_error', 272 | context: { 273 | recipient, 274 | consecutiveFailures: recipientLimits.consecutiveFailures, 275 | failureCooldown 276 | }, 277 | timestamp: new Date().toISOString() 278 | } 279 | }; 280 | } else { 281 | // Reset consecutive failures after cooldown 282 | recipientLimits.consecutiveFailures = 0; 283 | } 284 | } 285 | 286 | // Reset count if cooldown period has passed 287 | if (now - recipientLimits.lastReset > (this.config.rateLimiting?.cooldownPeriod ?? 1000)) { 288 | recipientLimits.count = 0; 289 | recipientLimits.lastReset = now; 290 | } 291 | 292 | // Check burst limit 293 | if (recipientLimits.count >= (this.config.rateLimiting?.burstLimit ?? 5)) { 294 | this.metrics.rate_limit_exceeded_total++; 295 | this.metrics.last_email_status = 'failure'; 296 | this.metrics.errors_by_type.rate_limit++; 297 | this.writeLog('debug', { 298 | recipient, 299 | event: 'banned', 300 | burstLimit: this.config.rateLimiting?.burstLimit ?? 5, 301 | cooldownPeriod: this.config.rateLimiting?.cooldownPeriod ?? 1000, 302 | message: 'Rate limit exceeded for recipient' 303 | }); 304 | 305 | throw { 306 | code: 'ERATELIMIT', 307 | message: 'Rate limit exceeded for recipient', 308 | details: { 309 | type: 'rate_limit_error', 310 | context: { 311 | recipient, 312 | burstLimit: this.config.rateLimiting?.burstLimit ?? 5, 313 | cooldownPeriod: this.config.rateLimiting?.cooldownPeriod ?? 1000 314 | }, 315 | timestamp: new Date().toISOString() 316 | } 317 | }; 318 | } 319 | 320 | recipientLimits.count++; 321 | } 322 | 323 | private async createConnection(): Promise { 324 | return new Promise((resolve, reject) => { 325 | const socket = new Socket(); 326 | 327 | socket.setTimeout(this.config.timeout!); 328 | 329 | socket.on('timeout', () => { 330 | socket.destroy(); 331 | this.metrics.connection_errors++; 332 | this.metrics.last_email_status = 'failure'; 333 | this.metrics.errors_by_type.timeout++; 334 | reject({ 335 | code: 'ETIMEDOUT', 336 | message: 'Connection timeout', 337 | details: { 338 | type: 'connection_error', 339 | context: { 340 | host: this.config.host, 341 | port: this.config.port, 342 | timeout: this.config.timeout 343 | }, 344 | timestamp: new Date().toISOString() 345 | } 346 | }); 347 | }); 348 | 349 | socket.connect({ 350 | host: this.config.host, 351 | port: this.config.port 352 | }, async () => { 353 | this.metrics.active_connections++; 354 | if (this.config.secure) { 355 | const tlsSocket = new TLSSocket(socket, { 356 | rejectUnauthorized: true, // Enable certificate validation 357 | minVersion: 'TLSv1.2', 358 | maxVersion: 'TLSv1.3', 359 | ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256', 360 | honorCipherOrder: true 361 | }); 362 | resolve(tlsSocket); 363 | } else { 364 | resolve(socket); 365 | } 366 | }); 367 | 368 | socket.on('error', (err: Error & { code?: string }) => { 369 | this.metrics.connection_errors++; 370 | this.metrics.last_email_status = 'failure'; 371 | this.metrics.errors_by_type.connection++; 372 | reject({ 373 | code: err.code || 'ECONNECTION', 374 | message: err.message, 375 | details: { 376 | type: 'connection_error', 377 | context: { 378 | host: this.config.host, 379 | port: this.config.port, 380 | originalError: err 381 | }, 382 | timestamp: new Date().toISOString() 383 | } 384 | }); 385 | }); 386 | }); 387 | } 388 | 389 | private async sendCommand(socket: Socket | TLSSocket, command: string): Promise { 390 | return new Promise((resolve, reject) => { 391 | const responseHandler = (data: Buffer) => { 392 | const response = data.toString(); 393 | socket.removeListener('data', responseHandler); 394 | resolve(response); 395 | }; 396 | 397 | socket.on('data', responseHandler); 398 | socket.write(command + '\r\n', (err) => { 399 | if (err) { 400 | socket.removeListener('data', responseHandler); 401 | this.metrics.last_email_status = 'failure'; 402 | this.metrics.errors_by_type.command++; 403 | reject({ 404 | code: 'ECOMMAND', 405 | message: 'Failed to send command', 406 | details: { 407 | type: 'command_error', 408 | context: { 409 | command: command.substring(0, 20) + '...', 410 | error: err 411 | }, 412 | timestamp: new Date().toISOString() 413 | } 414 | }); 415 | } 416 | }); 417 | }); 418 | } 419 | 420 | private generateBoundary(): string { 421 | return '----' + crypto.randomBytes(16).toString('hex'); 422 | } 423 | 424 | private sanitizeHeader(value: string): string { 425 | // Remove newlines and other potentially dangerous characters 426 | return value.replace(/[\r\n\t\v\f]/g, ''); 427 | } 428 | 429 | 430 | private sanitizePath(filePath: string): string { 431 | try { 432 | // Normalize the path to handle different path formats 433 | const normalizedPath = path.normalize(filePath); 434 | 435 | // Get the absolute path, using process.cwd() as base if path is relative 436 | const absolutePath = path.isAbsolute(normalizedPath) 437 | ? normalizedPath 438 | : path.join(process.cwd(), normalizedPath); 439 | 440 | // For debugging 441 | if(this.config.logging?.level === "debug"){ 442 | console.log('Path details:', { 443 | original: filePath, 444 | normalized: normalizedPath, 445 | absolute: absolutePath, 446 | exists: fs.existsSync(absolutePath), 447 | cwd: process.cwd() 448 | }); 449 | } 450 | 451 | // Verify the file exists 452 | if (!fs.existsSync(absolutePath)) { 453 | throw new Error(`File does not exist: ${filePath}`); 454 | } 455 | 456 | // Verify file is readable 457 | try { 458 | fs.accessSync(absolutePath, fs.constants.R_OK); 459 | } catch (err:any) { 460 | throw new Error(`File is not readable: ${filePath}`); 461 | } 462 | 463 | return absolutePath; 464 | } catch (err:any) { 465 | throw new Error(`Path validation failed: ${err?.message}`); 466 | } 467 | } 468 | 469 | private buildMimeMessage(options: MailOptions, boundary: string): string { 470 | let message = ''; 471 | 472 | // Headers 473 | message += 'MIME-Version: 1.0\r\n'; 474 | message += `From: ${this.sanitizeHeader(this.config.from)}\r\n`; 475 | message += `To: ${this.sanitizeHeader(Array.isArray(options.to) ? options.to.join(', ') : options.to)}\r\n`; 476 | if (options.cc) { 477 | message += `Cc: ${Array.isArray(options.cc) ? options.cc.join(', ') : options.cc}\r\n`; 478 | } 479 | message += `Subject: ${this.sanitizeHeader(options.subject)}\r\n`; 480 | message += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`; 481 | 482 | // Text/HTML Content 483 | if (options.text) { 484 | message += `--${boundary}\r\n`; 485 | message += 'Content-Type: text/plain; charset=utf-8\r\n\r\n'; 486 | message += options.text + '\r\n\r\n'; 487 | } 488 | 489 | if (options.html) { 490 | message += `--${boundary}\r\n`; 491 | message += 'Content-Type: text/html; charset=utf-8\r\n\r\n'; 492 | message += options.html + '\r\n\r\n'; 493 | } 494 | 495 | // Attachments 496 | if (options.attachments) { 497 | for (const attachment of options.attachments) { 498 | let content: Buffer; 499 | let filename: string; 500 | 501 | if (attachment.path) { 502 | try { 503 | const filePath = this.sanitizePath(attachment.path); 504 | content = fs.readFileSync(filePath); 505 | 506 | 507 | console.log("filePath", filePath) 508 | 509 | 510 | if (!attachment.filename) { 511 | filename = path.basename(filePath); 512 | } else { 513 | const fileExt = path.extname(filePath); 514 | filename = path.extname(attachment.filename) ? attachment.filename : attachment.filename + fileExt; 515 | } 516 | } catch (err) { 517 | this.metrics.last_email_status = 'failure'; 518 | this.metrics.errors_by_type.attachment++; 519 | throw { 520 | code: 'EATTACHMENT', 521 | message: 'Failed to read attachment file', 522 | details: { 523 | type: 'attachment_error', 524 | context: { 525 | path: attachment.path, 526 | error: err 527 | }, 528 | timestamp: new Date().toISOString() 529 | } 530 | }; 531 | } 532 | } else if (attachment.content) { 533 | content = Buffer.isBuffer(attachment.content) ? 534 | attachment.content : 535 | Buffer.from(attachment.content); 536 | filename = attachment.filename || 'attachment'; 537 | } else { 538 | continue; 539 | } 540 | 541 | const contentType = attachment.contentType || this.detectMimeType(filename); 542 | 543 | message += `--${boundary}\r\n`; 544 | message += `Content-Type: ${contentType}\r\n`; 545 | message += `Content-Disposition: attachment; filename="${filename}"\r\n`; 546 | message += `Content-Transfer-Encoding: ${attachment.encoding || 'base64'}\r\n\r\n`; 547 | message += content.toString('base64') + '\r\n\r\n'; 548 | } 549 | } 550 | 551 | message += `--${boundary}--\r\n.`; 552 | return message; 553 | } 554 | 555 | private detectMimeType(filename: string): string { 556 | const ext = path.extname(filename).toLowerCase(); 557 | const mimeTypesList = mimeTypes; 558 | 559 | return mimeTypesList[ext] || 'application/octet-stream'; 560 | } 561 | 562 | private shouldLog(level: string): boolean { 563 | const configLevel = this.config.logging?.level || 'info'; 564 | 565 | // If config level is debug, log everything 566 | if (configLevel === 'debug') { 567 | return true; 568 | } 569 | 570 | // For other levels, only log matching or higher priority events 571 | switch (configLevel) { 572 | case 'info': 573 | return level === 'info'; // Only log successful events 574 | case 'warn': 575 | return level === 'warn'; // Only log warnings 576 | case 'error': 577 | return level === 'error'; // Only log errors 578 | default: 579 | return false; 580 | } 581 | } 582 | 583 | private maskSensitiveData(data: any): any { 584 | if (!data) return data; 585 | 586 | const sensitiveFields = ['password', 'auth', 'token', 'key']; 587 | const masked = { ...data }; 588 | 589 | for (const field of sensitiveFields) { 590 | if (masked[field]) { 591 | masked[field] = '********'; 592 | } 593 | } 594 | 595 | return masked; 596 | } 597 | 598 | private writeLog(level: string, data: any): void { 599 | // First check if we should log this message 600 | if (!this.shouldLog(level)) { 601 | return; 602 | } 603 | 604 | try { 605 | // Mask sensitive data 606 | const maskedData = this.maskSensitiveData(data); 607 | 608 | const logEntry = { 609 | timestamp: new Date().toISOString(), 610 | level, 611 | ...maskedData 612 | }; 613 | 614 | // Add custom fields if configured 615 | if (this.config.logging?.customFields) { 616 | for (const field of this.config.logging.customFields) { 617 | if (data[field]) { 618 | logEntry[field] = data[field]; 619 | } 620 | } 621 | } 622 | 623 | // Format the log entry 624 | let formattedLog: string; 625 | if (this.config.logging?.format === 'text') { 626 | formattedLog = `[${logEntry.timestamp}] ${level.toUpperCase()}: ${JSON.stringify(maskedData)}\n`; 627 | } else { 628 | formattedLog = JSON.stringify(logEntry) + '\n'; 629 | } 630 | 631 | // Write to file if destination exists, otherwise write to console 632 | if (this.logFilePath) { 633 | fs.appendFileSync(this.logFilePath, formattedLog); 634 | } else { 635 | // Use appropriate console method based on level 636 | switch (level) { 637 | case 'error': 638 | console.error(formattedLog); 639 | break; 640 | case 'warn': 641 | console.warn(formattedLog); 642 | break; 643 | case 'debug': 644 | console.debug(formattedLog); 645 | break; 646 | default: 647 | console.log(formattedLog); 648 | } 649 | } 650 | } catch (error) { 651 | console.warn('Failed to write log:', error); 652 | } 653 | } 654 | 655 | public async sendMail(options: MailOptions): Promise { 656 | // Debug level logging 657 | this.writeLog('debug', { 658 | event: 'mail_attempt', 659 | recipients: options.to, 660 | subject: options.subject, 661 | timestamp: new Date().toISOString() 662 | }); 663 | 664 | // First verify connection before proceeding 665 | if (!await this.verifyConnection()) { 666 | this.metrics.last_email_status = 'failure'; 667 | this.metrics.errors_by_type.connection++; 668 | 669 | throw { 670 | code: 'ECONNECTION', 671 | message: 'SMTP connection failed, cannot send email', 672 | details: { 673 | type: 'connection_error', 674 | context: { 675 | host: this.config.host, 676 | port: this.config.port 677 | }, 678 | timestamp: new Date().toISOString() 679 | } 680 | }; 681 | } 682 | 683 | const recipients = [ 684 | ...(Array.isArray(options.to) ? options.to : [options.to]), 685 | ...(options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : []), 686 | ...(options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : []) 687 | ]; 688 | 689 | // Validate email format for all recipients 690 | for (const recipient of recipients) { 691 | if (!this.validateEmail(recipient)) { 692 | this.metrics.last_email_status = 'failure'; 693 | this.metrics.errors_by_type.validation++; 694 | throw { 695 | code: 'EINVALIDEMAIL', 696 | message: `Invalid email format: ${recipient}`, 697 | details: { 698 | type: 'validation_error', 699 | context: { 700 | recipient 701 | }, 702 | timestamp: new Date().toISOString() 703 | } 704 | }; 705 | } 706 | } 707 | 708 | // Check rate limits for each recipient if enabled 709 | if (this.config.rateLimiting?.perRecipient) { 710 | for (const recipient of recipients) { 711 | this.checkRateLimit(recipient); 712 | } 713 | } 714 | 715 | const startTime = Date.now(); 716 | let socket: Socket | TLSSocket | undefined; 717 | let currentCommand = ''; 718 | 719 | try { 720 | socket = await this.createConnection(); 721 | 722 | currentCommand = 'EHLO'; 723 | await this.sendCommand(socket, `EHLO ${this.config.host}`); 724 | 725 | if (!this.config.secure) { 726 | currentCommand = 'STARTTLS'; 727 | await this.sendCommand(socket, 'STARTTLS'); 728 | const tlsSocket = new TLSSocket(socket, { 729 | rejectUnauthorized: true, // Enable certificate validation 730 | minVersion: 'TLSv1.2', 731 | maxVersion: 'TLSv1.3', 732 | ciphers: 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256', 733 | honorCipherOrder: true 734 | }); 735 | } 736 | 737 | currentCommand = 'AUTH'; 738 | await this.sendCommand(socket, `AUTH LOGIN`); 739 | await this.sendCommand(socket, Buffer.from(this.config.auth.user).toString('base64')); 740 | await this.sendCommand(socket, Buffer.from(this.config.auth.pass).toString('base64')); 741 | 742 | currentCommand = 'MAIL FROM'; 743 | await this.sendCommand(socket, `MAIL FROM:<${this.config.from}>`); 744 | 745 | currentCommand = 'RCPT TO'; 746 | for (const recipient of recipients) { 747 | await this.sendCommand(socket, `RCPT TO:<${recipient}>`); 748 | } 749 | 750 | currentCommand = 'DATA'; 751 | await this.sendCommand(socket, 'DATA'); 752 | 753 | const boundary = this.generateBoundary(); 754 | const mimeMessage = this.buildMimeMessage(options, boundary); 755 | await this.sendCommand(socket, mimeMessage); 756 | 757 | const messageId = crypto.randomBytes(16).toString('hex'); 758 | const sendTime = Date.now() - startTime; 759 | 760 | // Reset consecutive failures on success 761 | if (this.config.rateLimiting?.perRecipient) { 762 | for (const recipient of recipients) { 763 | const limits = this.rateLimits.get(recipient); 764 | if (limits) { 765 | limits.consecutiveFailures = 0; 766 | } 767 | } 768 | } 769 | 770 | this.updateMetrics(true, sendTime); 771 | 772 | // Info level logging for success 773 | this.writeLog('info', { 774 | success: true, 775 | event: 'mail_success', 776 | messageId, 777 | recipients: options.to, 778 | subject: options.subject, 779 | sendTime 780 | }); 781 | 782 | return { 783 | success: true, 784 | messageId, 785 | timestamp: new Date(), 786 | recipients: recipients.join(', '), 787 | }; 788 | 789 | } catch (error: any) { 790 | // Store failed email details in metrics with enhanced tracking 791 | const failureDetails = { 792 | timestamp: new Date(), 793 | recipient: recipients.join(', '), 794 | subject: options.subject, 795 | error: { 796 | code: error.code, 797 | message: error.message, 798 | details: error.details 799 | }, 800 | command: currentCommand, 801 | attempt: 1 802 | }; 803 | 804 | this.metrics.failed_emails.push(failureDetails); 805 | this.metrics.failure_details.last_error = failureDetails; 806 | 807 | // Update per-recipient error counts 808 | for (const recipient of recipients) { 809 | const currentCount = this.metrics.failure_details.error_count_by_recipient.get(recipient) || 0; 810 | this.metrics.failure_details.error_count_by_recipient.set(recipient, currentCount + 1); 811 | } 812 | 813 | // Calculate average failures per recipient 814 | const totalRecipients = this.metrics.failure_details.error_count_by_recipient.size; 815 | const totalFailures = Array.from(this.metrics.failure_details.error_count_by_recipient.values()) 816 | .reduce((sum, count) => sum + count, 0); 817 | this.metrics.failure_details.avg_failures_per_recipient = totalRecipients ? totalFailures / totalRecipients : 0; 818 | 819 | // Update consecutive failures 820 | if (this.config.rateLimiting?.perRecipient) { 821 | for (const recipient of recipients) { 822 | const limits = this.rateLimits.get(recipient); 823 | if (limits) { 824 | limits.consecutiveFailures++; 825 | limits.lastFailure = Date.now(); 826 | } 827 | } 828 | } 829 | 830 | const sendTime = Date.now() - startTime; 831 | this.updateMetrics(false, sendTime); 832 | this.metrics.last_email_status = 'failure'; 833 | this.metrics.consecutive_failures++; 834 | this.metrics.last_error_timestamp = Date.now(); 835 | 836 | // Update error type metrics 837 | if (error.details?.type) { 838 | switch (error.details.type) { 839 | case 'connection_error': 840 | this.metrics.errors_by_type.connection++; 841 | break; 842 | case 'authentication_error': 843 | this.metrics.errors_by_type.authentication++; 844 | break; 845 | case 'rate_limit_error': 846 | this.metrics.errors_by_type.rate_limit++; 847 | break; 848 | case 'validation_error': 849 | this.metrics.errors_by_type.validation++; 850 | break; 851 | case 'timeout_error': 852 | this.metrics.errors_by_type.timeout++; 853 | break; 854 | case 'attachment_error': 855 | this.metrics.errors_by_type.attachment++; 856 | break; 857 | case 'command_error': 858 | this.metrics.errors_by_type.command++; 859 | break; 860 | default: 861 | this.metrics.errors_by_type.unknown++; 862 | } 863 | } else { 864 | this.metrics.errors_by_type.unknown++; 865 | } 866 | 867 | const errorDetails = { 868 | code: error.code || 'EUNKNOWN', 869 | message: error.message || 'An unknown error occurred', 870 | details: { 871 | type: error.details?.type || 'smtp_error', 872 | context: { 873 | ...error.details?.context, 874 | lastCommand: currentCommand, 875 | recipients, 876 | subject: options.subject, 877 | attemptNumber: 1, 878 | socketState: socket ? 'connected' : 'disconnected' 879 | }, 880 | timestamp: new Date().toISOString() 881 | } 882 | }; 883 | 884 | // Error level logging 885 | this.writeLog('error', { 886 | success: false, 887 | event: 'mail_failure', 888 | error: errorDetails, 889 | recipients: options.to, 890 | subject: options.subject, 891 | sendTime 892 | }); 893 | 894 | throw errorDetails; 895 | } finally { 896 | if (socket && !this.config.keepAlive) { 897 | socket.end(); 898 | this.metrics.active_connections--; 899 | } 900 | } 901 | } 902 | 903 | private updateMetrics(success: boolean, sendTime: number): void { 904 | // Update counters 905 | this.metrics.emails_total++; 906 | if (success) { 907 | this.metrics.emails_successful++; 908 | this.metrics.last_email_status = 'success'; 909 | this.metrics.consecutive_failures = 0; 910 | } else { 911 | this.metrics.emails_failed++; 912 | this.metrics.last_email_status = 'failure'; 913 | } 914 | 915 | // Update timing metrics 916 | const sendTimeSeconds = sendTime / 1000; 917 | this.metrics.email_send_duration_seconds.count++; 918 | this.metrics.email_send_duration_seconds.sum += sendTimeSeconds; 919 | this.metrics.email_send_duration_seconds.avg = 920 | this.metrics.email_send_duration_seconds.sum / this.metrics.email_send_duration_seconds.count; 921 | this.metrics.email_send_duration_seconds.max = 922 | Math.max(this.metrics.email_send_duration_seconds.max, sendTimeSeconds); 923 | this.metrics.email_send_duration_seconds.min = 924 | Math.min(this.metrics.email_send_duration_seconds.min, sendTimeSeconds); 925 | 926 | // Update histogram buckets 927 | Object.keys(this.metrics.email_send_duration_seconds.buckets).forEach(bucket => { 928 | if (sendTimeSeconds <= parseFloat(bucket)) { 929 | this.metrics.email_send_duration_seconds.buckets[bucket as keyof typeof this.metrics.email_send_duration_seconds.buckets]++; 930 | } 931 | }); 932 | 933 | // Update rate metrics 934 | const now = Date.now(); 935 | const timeWindow = 60000; // 1 minute 936 | this.metrics.email_send_rate = this.metrics.emails_total / ((now - this.metrics.last_email_timestamp) / timeWindow); 937 | this.metrics.last_email_timestamp = now; 938 | 939 | } 940 | 941 | public getMetrics(): Metrics { 942 | return { ...this.metrics }; 943 | } 944 | 945 | public async verifyConnection(): Promise { 946 | try { 947 | const socket = await this.createConnection(); 948 | socket.end(); 949 | return true; 950 | } catch { 951 | this.metrics.last_email_status = 'failure'; 952 | this.metrics.errors_by_type.connection++; 953 | return false; 954 | } 955 | } 956 | } 957 | 958 | export default FastMailer; 959 | --------------------------------------------------------------------------------