Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c875cbb18 | |||
| 735464e8e6 | |||
| e6a1f50554 | |||
| 530ebbf3e4 | |||
| 048f038e36 | |||
| e375adb80a | |||
| 9d7da5bc25 | |||
| 41fe7a8a47 | |||
| f3f1f58b67 | |||
| 9e0e77737b | |||
| 5de3344905 | |||
| ae34314f54 | |||
| 5b473de354 | |||
| 1a108fa8b7 | |||
| badabe753a | |||
| c2d3ace0dd | |||
| fcea194cf6 | |||
| b90650c660 | |||
| 2206abd04b | |||
| d54831765b | |||
| dd4ac9fa3d | |||
| aed9151998 | |||
| 5d4bf4eff8 | |||
| 9027125520 | |||
| ee561c0823 | |||
| 95cb5d7840 | |||
| 2f46b3c9f3 | |||
| 7bd94884f4 | |||
| 405990563b | |||
| bf9f805c71 | |||
| 28cbf84f97 | |||
| d24e51117d | |||
| 92fde9d0d7 | |||
| b81bda6ce8 | |||
| 9b3f5c458d | |||
| 3ba47f9a71 | |||
| 2ab2e30336 | |||
| 8ce6c88d58 | |||
| facae93e4b | |||
| 0eb4963247 | |||
| 02dd3c77b5 | |||
| 93995d5031 | |||
| 554d245c0c | |||
| e3cb35a036 | |||
| 3a95ea9f4e | |||
| 99f57dba76 | |||
| 415e28038d | |||
| 7bda406624 | |||
| 8282610307 | |||
| 5269c20770 | |||
| f1fb4c8495 | |||
| 5faca8c1b6 | |||
| 61778bdba8 | |||
| ab19130904 | |||
| 646aa7106b | |||
| b0f167f6da | |||
| 4d8d802006 | |||
| 6ee1d6e917 | |||
| f877ad9676 | |||
| fe817dde00 | |||
| 272973702e | |||
| c776dab2c0 | |||
| 74692c4aa5 | |||
| 71183b35c0 | |||
| ae73de19b2 | |||
| a2b413a78f | |||
| 739eeb63aa | |||
| eb26a62a87 | |||
| ad0ab6c103 | |||
| 37e1ecefd2 | |||
| e6251ab655 | |||
| 53b64025f3 | |||
| 40db395591 | |||
| 2c244c4a9a | |||
| 0baf2562b7 | |||
| 64da8d9100 | |||
| b11fea7334 | |||
| 6c8458f63c | |||
| 455b0085ec | |||
| 2b2fe940c4 | |||
| e1a7b3e8f7 | |||
| 191c4160c1 | |||
| 2e75961d1c | |||
| 88099e120a | |||
| 77ff948404 | |||
| 0e610cba16 | |||
| 8d59d617f1 | |||
| 6aa54d974e | |||
| 2aeb52bf13 | |||
| 243a45d24c | |||
| cfea44742a | |||
| 073c8378c7 | |||
| af408d38c9 | |||
| c3b14c0f58 | |||
| 69304dc839 | |||
| a3721f7a74 | |||
| 20583beb35 | |||
| b8ea8f660e | |||
| 5a45d6cd45 | |||
| 84196f9b13 | |||
| 4c9fd22a86 | |||
| 5b33623c2d | |||
| 58f4a123d2 | |||
| 11a2ae6b27 | |||
| 4e4c7df558 | |||
| 3d669ed9dd | |||
| 6e19e30f87 | |||
| dc5c0b2584 | |||
| 35712b18bc | |||
| 9958c036a0 | |||
| 14c9fbdc3c | |||
| 4fd3ec2958 | |||
| f2e9ff0a51 | |||
| cb52446f65 | |||
| 0907949f8a | |||
| 9629329bc2 | |||
| f651cd1c2f | |||
| a7438a7cd6 | |||
| e0f6e3237b | |||
| 1b141ec8f3 | |||
| 7d28d23bbd | |||
| 53f5e30b23 | |||
| 7344bf0f70 | |||
| 4905595cbb | |||
| f058b2d1e7 | |||
| 6fcc3feb73 | |||
| 50350bd78d | |||
| f065a9c952 | |||
| 72898c67b7 | |||
| ca53816b41 | |||
| ac419e7b79 | |||
| 7c0f9b4e44 | |||
| d584f3584c | |||
| a4353b10bb | |||
| b2f25c49b6 | |||
| d3255a7e14 | |||
| 2564d0874b | |||
| ca111f4783 | |||
| b6dd281a54 | |||
| 645790d0c2 | |||
| 535b055664 | |||
| 2eeb731669 | |||
| c3ae995372 | |||
| 15e7a3032c | |||
| 10ab09894b | |||
| 38811dbf23 | |||
| 3f220996ee | |||
| b0a0078ad0 | |||
| ecb913843c | |||
| 162795802f | |||
| b1890f59ee | |||
| 5c85188183 | |||
| f37cddf26d | |||
| f3f06ed06d | |||
| 07f03eb834 | |||
| e7174e8630 | |||
| 186e94c1a2 | |||
| fb424d814c | |||
| 0ad5dfd6ee | |||
| fbaafa909b | |||
| f1cc7fd340 | |||
| deec61da42 | |||
| 190ae11667 | |||
| f4ace3999d | |||
| 8b857e3d1d | |||
| 7aaf8f2595 | |||
| 39b634b6bb | |||
| 4624fdbe10 | |||
| 858794799b | |||
| cb33dd26d0 | |||
| d3d197d9d3 | |||
| 0e914a3366 | |||
| 747478f0f9 | |||
| b61de33ee0 | |||
| 970c0d5c60 | |||
| fe2069c48e | |||
| 63781ab1bd | |||
| 0b155d6925 | |||
| 076aac27ce | |||
| 7f84405279 | |||
| 13ef31c13f | |||
| 5cf4c0f150 | |||
| 04b7552b34 | |||
| 1528d29b0d | |||
| 9d895898b1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,4 +19,5 @@ dist_*/
|
||||
|
||||
# custom
|
||||
**/.claude/settings.local.json
|
||||
data/
|
||||
.nogit/data/
|
||||
readme.plan.md
|
||||
|
||||
BIN
.playwright-mcp/dcrouter-scrollbar-issue.png
Normal file
BIN
.playwright-mcp/dcrouter-scrollbar-issue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
.playwright-mcp/page-2026-02-01T23-10-23-737Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-01T23-10-23-737Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
.playwright-mcp/page-2026-02-01T23-11-19-449Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-01T23-11-19-449Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
.playwright-mcp/page-2026-02-01T23-12-03-126Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-01T23-12-03-126Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
.playwright-mcp/page-2026-02-01T23-12-15-576Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-01T23-12-15-576Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
365
changelog.md
365
changelog.md
@@ -1,5 +1,287 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-11 - 5.0.1 - fix(deps/tests)
|
||||
bump two dependencies and disable cache in tests
|
||||
|
||||
- Bumped @api.global/typedrequest from ^3.2.5 to ^3.2.6
|
||||
- Bumped @push.rocks/smartradius from ^1.1.0 to ^1.1.1
|
||||
- Disabled cache in tests by adding cacheConfig: { enabled: false } to DcRouter instantiation in test/test.jwt-auth.ts, test/test.opsserver-api.ts, and test/test.protected-endpoint.ts
|
||||
|
||||
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mta)
|
||||
migrate internal MTA to @push.rocks/smartmta and remove legacy mail/deliverability implementation
|
||||
|
||||
- Replace ~27k LOC custom MTA (ts/mail/, ts/deliverability/) with @push.rocks/smartmta v5.2.1 (TypeScript+Rust hybrid)
|
||||
- Remove many SMTP client/server test suites and test helpers; testing approach and fixtures changed/removed
|
||||
- Upgrade dependencies: @push.rocks/smartproxy -> 23.1.2, @push.rocks/smartdns -> 7.8.0, add @push.rocks/smartmta@5.2.1; bump other minor deps
|
||||
- API differences: updateEmailRoutes() replaces updateRoutes(); UnifiedEmailServer exposes dkimCreator publicly; bounce/suppression APIs moved to emailServer.* helpers; Email class and IAttachment types moved into @push.rocks/smartmta exports
|
||||
- SmartProxy route validation stricter: forward actions must use targets (array) instead of target (singular) — tests/configs updated accordingly
|
||||
- DKIM generation/serving moved to smartmta (dcrouter no longer manages DKIM keys directly)
|
||||
|
||||
## 2026-02-10 - 4.1.1 - fix(smartproxy)
|
||||
upgrade @push.rocks/smartproxy to ^23.1.0 and adapt code/tests for its async getStatistics() API
|
||||
|
||||
- Bumped dependency @push.rocks/smartproxy 22.4.2 → 23.1.0 in package.json
|
||||
- Changed ts/monitoring/classes.metricsmanager.ts to await smartProxy.getStatistics() (was synchronous)
|
||||
- Updated multiple tests to set cacheConfig: { enabled: false } and added socketTimeouts where appropriate
|
||||
- Improved SMTP test servers: handle multi-line input, drop data for packet-loss simulation, and ignore socket errors to make tests more robust
|
||||
- Added migration notes to readme.hints.md documenting SmartProxy v23.1.0 changes (async getStatistics, Rust proxy behavior)
|
||||
|
||||
## 2026-02-10 - 4.1.0 - feat(cache)
|
||||
add persistent smartdata-backed cache with LocalTsmDb, cache cleaner, and DcRouter integration
|
||||
|
||||
- Introduce CacheDb and CacheCleaner using @push.rocks/smartdata and @push.rocks/smartmongo (LocalTsmDb) for persistent caching
|
||||
- Integrate cache initialization, console summary, and graceful shutdown into DcRouter (options.cacheConfig and setupCacheDb())
|
||||
- Require svDb() decorators on concrete cache document classes; remove decorators from the abstract CachedDocument base class
|
||||
- Switch CacheCleaner to smartdata getInstances() + per-document delete() instead of deleteMany
|
||||
- Adapt to LocalTsmDb API changes (folderPath option and start() returning connectionUri) and initialize SmartdataDb with mongoDbUrl/mongoDbName
|
||||
- Remove experimentalDecorators and emitDecoratorMetadata from tsconfig to use TC39 Stage 3 decorators (smartdata v7+ compatibility)
|
||||
- Add package.json exports mapping (remove main/typings entries) to expose dist entry points
|
||||
- Add README documentation for the Smartdata Cache System and configuration/usage examples
|
||||
|
||||
## 2026-02-03 - 4.0.0 - BREAKING CHANGE(config)
|
||||
convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing
|
||||
|
||||
- Removed server-side 'updateConfiguration' TypedHandler and the private updateConfiguration() method; getConfiguration remains as a read-only handler.
|
||||
- Removed IReq_UpdateConfiguration interface from request typings; IReq_GetConfiguration marked as read-only.
|
||||
- Removed client-side editing functionality: ops-view-config editing state and methods, Edit/Save/Cancel buttons, and updateConfigurationAction; ops-view-config enhanced to display read-only configuration (badges for booleans, array pills, icons, formatted numbers/bytes, empty states, etc.).
|
||||
- Tests updated: replaced configuration update tests with verifyIdentity tests and added a read-only configuration access test.
|
||||
- Documentation updated to reflect configuration is read-only (readme.md, ts_web/readme.md, ts_interfaces/readme.md, readme.hints.md).
|
||||
- Dependencies adjusted: bumped @push.rocks/smartdata to ^7.0.15 and added @push.rocks/smartmongo ^5.1.0; ts/plugins updated to import/export smartmongo.
|
||||
|
||||
## 2026-02-02 - 3.1.0 - feat(web)
|
||||
determine initial UI view from URL and wire selected view to appdash; add interface and web README files; bump various dependencies
|
||||
|
||||
- UI: derive initial active view from window.location.pathname so the dashboard supports deep linking and bookmarks (ts_web/appstate.ts)
|
||||
- UI: pass selectedView to dees-simple-appdash by adding a currentViewTab getter in ops-dashboard (ts_web/elements/ops-dashboard.ts)
|
||||
- Docs: add TypeScript interfaces README for @serve.zone/dcrouter-interfaces (ts_interfaces/readme.md)
|
||||
- Docs: add/update web module README detailing features, routing, and build instructions (ts_web/readme.md) and expand main project README
|
||||
- Deps: bump multiple dependencies in package.json (notable bumps: @api.global/typedrequest -> ^3.2.5, @design.estate/dees-catalog -> ^3.42.0, @design.estate/dees-element -> ^2.1.6, @push.rocks/projectinfo -> ^5.0.2, @push.rocks/smartdata -> ^5.16.7, @push.rocks/smartpromise -> ^4.2.3, @push.rocks/smartradius -> ^1.1.0, @push.rocks/smartstate -> ^2.0.30, mailauth -> ^4.12.1)
|
||||
|
||||
## 2026-02-01 - 3.0.0 - BREAKING CHANGE(deps)
|
||||
upgrade major dependencies, migrate action.target to action.targets (array), adapt to SmartRequest API changes, and add RADIUS server support
|
||||
|
||||
- Bumped many major dependencies: @api.global/typedserver 3.x → 8.3.0, @api.global/typedsocket 3.x → 4.1.0, @apiclient.xyz/cloudflare 6.x → 7.1.0, @design.estate/dees-catalog 1.x → 3.41.4, @push.rocks/smartpath 5.x → 6.x, @push.rocks/smartproxy 19.x → 22.x, @push.rocks/smartrequest 2.x → 5.x, uuid 11.x → 13.x, @types/node 25.1.0 → 25.2.0
|
||||
|
||||
## 2026-02-01 - 2.13.0 - feat(radius)
|
||||
add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers
|
||||
|
||||
- Introduce full RADIUS module under ts/radius: classes.radius.server, classes.vlan.manager, classes.accounting.manager (authentication, VLAN mapping, OUI patterns, accounting, persistence).
|
||||
- Integrate RADIUS into DcRouter: add radiusConfig option, setupRadiusServer(), updateRadiusConfig(), start/stop lifecycle handling and startup summary output.
|
||||
- Add OpsServer RadiusHandler (ts/opsserver/handlers/radius.handler.ts) exposing TypedRequest endpoints for client management, VLAN mappings, accounting reports and statistics.
|
||||
- Add typed request interfaces for RADIUS under ts_interfaces/requests/radius.ts and export them from the requests index.
|
||||
- Wire smartradius into plugins (ts/plugins.ts) and export the new module; export RADIUS from ts/index.ts and re-export RADIUS types from classes.dcrouter.
|
||||
- Update package.json & npmextra.json: add tswatch script and dev watcher configuration, add @push.rocks/smartradius dependency and a test_watch/devserver.ts dev server entrypoint.
|
||||
- Refactor several web UI components (ops-dashboard, ops-view-*) to use 'accessor' for @state properties (small UI state API adjustments).
|
||||
- Documentation: update readme.hints.md with RADIUS integration notes and examples.
|
||||
|
||||
## 2026-02-01 - 2.12.6 - fix(tests)
|
||||
update tests and test helpers to current email/DNS APIs, use non-privileged ports, and improve robustness and resilience
|
||||
|
||||
- Email tests: switch to IEmailConfig properties (domains, routes), use router.emailServer (not unifiedEmailServer), change to non-privileged ports (e.g. 2525) and use fs.rmSync for cleanup.
|
||||
- SMTP client helper: add pool and domain options; adjust tests to use STARTTLS (secure: false) and tolerate TLS/cipher negotiation failures with try/catch fallbacks.
|
||||
- DNS tests: replace dnsDomain with dnsNsDomains and dnsScopes; test route generation without starting services, verify route names/domains, and create socket handlers without binding privileged ports.
|
||||
- Socket-handler tests: use high non-standard ports for route/handler tests, verify route naming (email-port-<port>-route), ensure handlers are functions and handle errors gracefully without starting full routers.
|
||||
- Integration/storage/rate-limit tests: add waits for async persistence, create/cleanup test directories, return and manage test server instances, relax strict assertions (memory threshold, rate-limiting enforcement) and make tests tolerant of implementation differences.
|
||||
- Misc: use getAvailablePort in perf test setup, export tap.start() where appropriate, and generally make tests less brittle by adding try/catch, fallbacks and clearer logs for expected non-deterministic behavior.
|
||||
|
||||
## 2026-02-01 - 2.12.5 - fix(mail)
|
||||
migrate filesystem helpers to fsUtils, update DKIM and mail APIs, harden SMTP client, and bump dependencies
|
||||
|
||||
- Introduce plugins.fsUtils compatibility layer and replace usages of plugins.smartfile.* with plugins.fsUtils.* across storage, routing, deliverability, and paths to support newer smartfile behaviour
|
||||
- Update DKIM signing/verifying to new mailauth API: use signingDomain/selector/privateKey and read keys from dkimCreator before signing; adjust verifier fields to use signingDomain
|
||||
- Harden SMTP client CommandHandler: add MAX_BUFFER_SIZE, socket close/error handlers, robust cleanup, clear response buffer, and adjust command/data timeouts; reduce default SOCKET_TIMEOUT to 45s
|
||||
- Use SmartFileFactory for creating SmartFile attachments and update saving/loading to use fsUtils async/sync helpers
|
||||
- Switch test runners to export default tap.start(), relax some memory-test thresholds, and add test helper methods (recordAuthFailure, recordError)
|
||||
- Update package.json: simplify bundle script and bump multiple devDependencies/dependencies to compatible versions
|
||||
|
||||
## 2025-01-29 - 2.13.0 - feat(socket-handler)
|
||||
Implement socket-handler mode for DNS and email services, enabling direct socket passing from SmartProxy
|
||||
|
||||
- Add `dnsDomain` configuration option that automatically sets up DNS server with DNS-over-HTTPS (DoH) support
|
||||
- Implement socket-handler mode for email services with `useSocketHandler` flag in email configuration
|
||||
- Update SmartProxy route generation to create socket-handler actions instead of port forwarding
|
||||
- Add automatic route creation for DNS paths `/dns-query` and `/resolve` when dnsDomain is configured
|
||||
- Enhance UnifiedEmailServer with `handleSocket` method for direct socket processing
|
||||
- Configure DnsServer with `manualHttpsMode: true` to prevent HTTPS port binding while enabling DoH
|
||||
- Improve performance by eliminating internal port forwarding overhead
|
||||
- Update documentation with socket-handler mode configuration and benefits
|
||||
|
||||
## 2025-05-16 - 2.12.0 - feat(smartproxy)
|
||||
Update documentation and configuration guides to adopt new route-based SmartProxy architecture
|
||||
|
||||
- Revise SmartProxy implementation hints in readme.hints.md to describe route-based configuration with glob pattern matching
|
||||
- Add migration examples showing transition from old direct configuration to new route-based style
|
||||
- Update DcRouter and SMTP port configuration to generate SmartProxy routes for email handling (ports 25, 587, 465 mapped to internal services)
|
||||
- Enhance integration documentation with examples for HTTP and email services using the new SmartProxy routes
|
||||
|
||||
## 2025-05-16 - 2.11.2 - fix(dependencies)
|
||||
Update dependency versions and adjust test imports to use new packages
|
||||
|
||||
- Upgraded @git.zone/tsbuild from ^2.3.2 to ^2.5.1
|
||||
- Upgraded @git.zone/tstest/tapbundle from ^1.0.88 to ^1.9.0 and replaced @push.rocks/tapbundle imports in tests
|
||||
- Upgraded @push.rocks/smartlog from ^3.0.3 to ^3.1.2
|
||||
- Upgraded @push.rocks/smartproxy from ^10.2.0 to ^18.1.0
|
||||
- Upgraded mailauth from ^4.8.4 to ^4.8.5
|
||||
|
||||
## 2025-05-08 - 2.11.1 - fix(platform)
|
||||
Update commit info with no functional changes; regenerated commit information.
|
||||
|
||||
|
||||
## 2025-05-08 - 2.11.0 - feat(platformservice)
|
||||
Expose DcRouter and update package visibility. Changed package.json 'private' flag from true to false to allow public publication, and added export of DcRouter in ts/index.ts for improved API accessibility.
|
||||
|
||||
- Changed package.json: set 'private' to false
|
||||
- Added export for DcRouter in ts/index.ts
|
||||
|
||||
## 2025-05-08 - 2.10.0 - feat(config): Implement standardized configuration system
|
||||
Create a comprehensive configuration system with validation, defaults, and documentation
|
||||
|
||||
- Added consistent configuration interfaces across all services
|
||||
- Implemented validation for all configuration objects with detailed error reporting
|
||||
- Added default values for optional configuration parameters
|
||||
- Created an extensive documentation system for configuration options
|
||||
- Added migration helpers for managing configuration format changes
|
||||
- Enhanced platform service to load configuration from multiple sources (file, environment, code)
|
||||
- Updated email and SMS services to use the new configuration system
|
||||
|
||||
## 2025-05-08 - 2.9.0 - feat(errors): Implement comprehensive error handling system
|
||||
Enhance error handling with structured errors, consistent patterns, and improved logging
|
||||
|
||||
- Added domain-specific error classes for better error categorization and handling
|
||||
- Created comprehensive error codes for all service types (email, MTA, security, etc.)
|
||||
- Implemented detailed error context with severity, category, and recoverability classification
|
||||
- Added utilities for error conversion, formatting, and handling with automatic retry mechanisms
|
||||
- Enhanced logging with correlation tracking, context support, and structured data
|
||||
- Created middleware for handling errors in HTTP requests with proper status code mapping
|
||||
- Added retry with exponential backoff for transient failures
|
||||
|
||||
## 2025-05-08 - 2.8.9 - fix(types)
|
||||
Fix TypeScript build errors and improve API type safety across platformservice interfaces
|
||||
|
||||
- Fixed interface placement in EmailService and MtaConnector classes
|
||||
- Aligned DeliveryStatus enum and updated ApiManager handlers with proper type-safe signatures
|
||||
- Added comprehensive TypeScript interfaces for ISendEmailOptions, ITemplateContext, IValidateEmailOptions, IValidationResult, and IEmailServiceStats
|
||||
- Removed circular dependencies in type definitions and added proper type assertions
|
||||
- Improved test stability by handling race conditions in SenderReputationMonitor and IPWarmupManager; external DNS lookups are disabled under test environment
|
||||
|
||||
## 2025-05-08 - 2.8.8 - fix(types): Fix TypeScript build errors and improve API interfaces
|
||||
Fix TypeScript build errors caused by interface placement and improve API type alignment
|
||||
|
||||
- Fixed interface placement in EmailService and MtaConnector classes
|
||||
- Aligned DeliveryStatus enum with EmailSendJob implementation
|
||||
- Added proper method signatures for API endpoint handlers in ApiManager class
|
||||
- Updated getStats and checkEmailStatus methods to conform to API contracts
|
||||
- Implemented type-safe return values for all API methods
|
||||
- Fixed circular dependencies in type definitions
|
||||
- Added proper type assertion where needed to satisfy TypeScript compiler
|
||||
|
||||
## 2025-05-08 - 2.8.7 - feat(types): Add comprehensive TypeScript interfaces for API types
|
||||
Improve type safety across the platform by adding detailed TypeScript interfaces for APIs
|
||||
|
||||
- Added ISendEmailOptions interface with complete documentation for email sending options
|
||||
- Created ITemplateContext interface for email template rendering with full type safety
|
||||
- Added IValidateEmailOptions and IValidationResult interfaces for email validation
|
||||
- Improved IEmailServiceStats interface with detailed statistics types
|
||||
- Added IEmailStatusResponse and IEmailStatusDetails interfaces for MTA status checking
|
||||
- Updated sendEmail and other methods to use these new interfaces instead of 'any'
|
||||
- Removed need for type assertions in various components
|
||||
|
||||
## 2025-05-08 - 2.8.6 - fix(tests)
|
||||
fix: Improve test stability by handling race conditions in SenderReputationMonitor and IPWarmupManager. Disable filesystem operations and external DNS lookups during tests by checking NODE_ENV, add proper cleanup of singleton instances and active timeouts to ensure consistent test environment.
|
||||
|
||||
- Bumped version from 2.8.4 to 2.8.5 in package.json and changelog.md
|
||||
- Improved SenderReputationMonitor to skip filesystem operations and DNS record loading when NODE_ENV is set to test
|
||||
- Added cleanup of singleton instances and active timeouts in test files
|
||||
- Updated readme.plan.md with roadmap items for test stability
|
||||
|
||||
## 2025-05-08 - 2.8.5 - fix(tests): Improve test stability by fixing race conditions
|
||||
Enhance the SenderReputationMonitor tests to prevent race conditions and make tests more reliable
|
||||
|
||||
- Modified SenderReputationMonitor to detect test environment and disable filesystem operations
|
||||
- Added proper cleanup of singleton instances and timeouts between tests
|
||||
- Disabled DNS lookups during tests to prevent external dependencies
|
||||
- Set a consistent test environment using NODE_ENV=test
|
||||
- Made all tests independent of each other to prevent shared state issues
|
||||
|
||||
## 2025-05-08 - 2.8.4 - fix(mail)
|
||||
refactor(mail): Remove Mailgun references from PlatformService. Update keywords, error messages, and documentation to use MTA exclusively.
|
||||
|
||||
- Removed Mailgun integration from keywords in package.json and npmextra.json
|
||||
- Updated EmailService to remove Mailgun API key usage and reference MTA instead
|
||||
- Updated changelog.md and readme.md to reflect removal of Mailgun and update examples
|
||||
- Revised error messages to mention 'MTA not configured' instead of generic provider errors
|
||||
- Updated readme.plan.md to document Mailgun removal
|
||||
|
||||
## 2025-05-08 - 2.8.3 - refactor(mail): Remove Mailgun references
|
||||
Remove all Mailgun references from the codebase since it's no longer used as an email provider
|
||||
|
||||
- Removed "mailgun integration" from keywords in package.json and npmextra.json
|
||||
- Updated comments and documentation in EmailService to remove Mailgun mentions
|
||||
- Updated error messages to reference MTA instead of generic email providers
|
||||
- Updated the readme email example to use PlatformService reference instead of Mailgun API key
|
||||
|
||||
## 2025-05-08 - 2.8.2 - fix(tests)
|
||||
Fix outdated import paths in test files for dcrouter and ratelimiter modules
|
||||
|
||||
- Updated dcrouter import from '../ts/dcrouter/index.js' to '../ts/classes.dcrouter.js'
|
||||
- Updated ratelimiter import from '../ts/mta/classes.ratelimiter.js' to '../ts/mail/delivery/classes.ratelimiter.js'
|
||||
|
||||
## 2025-05-08 - 2.8.1 - fix(readme)
|
||||
Update readme with consolidated email system improvements and modular directory structure
|
||||
|
||||
Clarify that the platform now organizes email functionality into distinct directories (mail/core, mail/delivery, mail/routing, mail/security, mail/services) and update the diagram and key features list accordingly. Adjust code examples to reflect explicit module imports and the use of SzPlatformService.
|
||||
|
||||
- Changed description of consolidated email configuration to include 'streamlined directory structure'.
|
||||
- Updated mermaid diagram to show 'Mail System Structure' with separate components for core, delivery, routing, security, and services.
|
||||
- Modified key features list to document modular directory structure.
|
||||
- Revised code sample imports to use explicit paths and SzPlatformService.
|
||||
|
||||
## 2025-05-08 - 2.8.0 - feat(docs)
|
||||
Update documentation to include consolidated email handling and pattern‑based routing details
|
||||
|
||||
- Extended MTA section to describe the new unified email processing system with forward, MTA, and process modes
|
||||
- Updated system diagram to reflect DcRouter integration with UnifiedEmailServer, DeliveryQueue, DeliverySystem, and RateLimiter
|
||||
- Revised readme.plan.md checklists to mark completed features in core architecture, multi‑modal processing, unified queue, and DcRouter integration
|
||||
|
||||
## 2025-05-08 - 2.7.0 - feat(dcrouter)
|
||||
Implement unified email configuration with pattern‐based routing and consolidated email processing. Migrate SMTP forwarding and store‐and‐forward into a single, configuration-driven system that supports glob pattern matching in domain rules.
|
||||
|
||||
- Introduced IEmailConfig interface to consolidate MTA, forwarding, and processing settings.
|
||||
- Added pattern-based domain routing with glob patterns (e.g., '*@example.com', '*@*.example.net').
|
||||
- Reworked DcRouter integration to expose unified email handling and updated readme.plan.md and changelog.md accordingly.
|
||||
- Removed deprecated SMTP forwarding components in favor of the consolidated approach.
|
||||
|
||||
## 2025-05-08 - 2.7.0 - feat(dcrouter)
|
||||
Implement consolidated email configuration with pattern-based routing
|
||||
|
||||
- Added new pattern-based email routing with glob patterns (e.g., `*@task.vc`, `*@*.example.net`)
|
||||
- Consolidated all email functionality (MTA, forwarding, processing) under a unified `emailConfig` interface
|
||||
- Implemented domain router with pattern specificity calculation for most accurate matching
|
||||
- Removed deprecated components (SMTP forwarding, Store-and-Forward) in favor of the unified approach
|
||||
- Updated DcRouter tests to use the new consolidated email configuration pattern
|
||||
- Enhanced inline documentation with detailed interface definitions and configuration examples
|
||||
- Updated implementation plan with comprehensive component designs for the unified email system
|
||||
|
||||
## 2025-05-07 - 2.6.0 - feat(dcrouter)
|
||||
Implement integrated DcRouter with comprehensive SmartProxy configuration, enhanced SMTP processing, and robust store‐and‐forward email routing
|
||||
|
||||
- Marked completion of tasks in readme.plan.md with [x] flags for SMTP server setup, email processing pipeline, queue management, and delivery system.
|
||||
- Reworked DcRouter to use direct SmartProxy configuration, separating smtpConfig and smtpForwarding approaches.
|
||||
- Added new components for delivery queue and delivery system with persistent storage support.
|
||||
- Improved SMTP server implementation with TLS support, event handlers for connection, authentication, sender/recipient validation, and data processing.
|
||||
- Refined domain-based routing and transformation logic in EmailProcessor with metrics and logging.
|
||||
- Updated exported modules in dcrouter index to include SMTP store‐and‐forward components.
|
||||
- Enhanced inline documentation and code comments for configuration interfaces and integration details.
|
||||
|
||||
## 2025-05-07 - 2.5.0 - feat(dcrouter)
|
||||
Enhance DcRouter configuration and update documentation
|
||||
|
||||
- Added new implementation hints (readme.hints.md) and planning documentation (readme.plan.md) outlining removal of SzPlatformService dependency and improvements in SMTP forwarding, domain routing, and certificate management.
|
||||
- Introduced new interfaces: ISmtpForwardingConfig and IDomainRoutingConfig for precise SMTP and HTTP domain routing configuration.
|
||||
- Refactored DcRouter classes to support direct integration with SmartProxy and enhanced MTA functionality, including SMTP port configuration and improved TLS handling.
|
||||
- Updated supporting modules such as SmtpPortConfig and EmailDomainRouter to provide better routing and security options.
|
||||
- Enhanced test coverage across dcrouter, rate limiter, IP warmup manager, and email authentication, ensuring backward compatibility and improved quality.
|
||||
|
||||
## 2025-05-07 - 2.4.2 - fix(tests)
|
||||
Update test assertions and singleton instance references in DMARC, integration, and IP warmup manager tests
|
||||
|
||||
@@ -30,86 +312,7 @@ Enhance email integration by updating @push.rocks/smartmail to ^2.1.0 and improv
|
||||
- Updated readme.plan.md with detailed roadmap for further performance, security, analytics, and deliverability enhancements
|
||||
- Expanded test suite to cover smartmail integration, validation, templating, and conversion between formats
|
||||
|
||||
## 2025-05-04 - 2.3.1 - fix(platformservice)
|
||||
Update dependency versions and refactor import paths for improved compatibility; add initial DcRouter plan documentation.
|
||||
|
||||
- Upgrade @git.zone/tsbuild to ^2.3.2 and @push.rocks/tapbundle to ^6.0.3.
|
||||
- Upgrade @api.global/typedserver to ^3.0.74 and update related API dependencies (cloudflare, letterxpress).
|
||||
- Upgrade smartdata to ^5.15.1, add smartdns (^6.2.2), upgrade smartproxy to ^10.0.2, smartrequest to ^2.1.0, smartrule to ^2.0.1, and smartrx to ^3.0.10.
|
||||
- Upgrade @serve.zone/interfaces to ^5.0.4 and @tsclass/tsclass to ^9.1.0; update mailauth to ^4.8.4.
|
||||
- Add packageManager field in package.json for PNPM configuration.
|
||||
- Add readme.plan.md detailing the DcRouter implementation plan.
|
||||
- Refactor import paths in several TS files (e.g. ts/plugins.ts, ts/mta classes) for consistency.
|
||||
|
||||
## 2025-03-15 - 2.3.0 - feat(platformservice)
|
||||
Add AIBridge module and refactor service file paths for improved module organization
|
||||
|
||||
- Added new AIBridge class in ts/aibridge/classes.aibridge.ts.
|
||||
- Renamed letter service file from ts/letter/letterservice.ts to ts/letter/classes.letterservice.ts and updated its index.
|
||||
- Updated platformservice.ts to import letter and SMS services from new paths.
|
||||
- Renamed SMS service file from ts/sms/smsservice.ts to ts/sms/classes.smsservice.ts and updated its index accordingly.
|
||||
|
||||
## 2025-03-15 - 2.2.1 - fix(platformservice)
|
||||
Refactor module structure to update import paths and file organization
|
||||
|
||||
- Removed obsolete file 'ts/classes.platformservice.ts' and updated references to use 'ts/platformservice.ts'.
|
||||
- Updated import paths in PlatformServiceDb, EmailService, and other modules to use new file structure.
|
||||
- Renamed and moved files in the email, mta, letter, and sms directories to align with new module layout.
|
||||
- Fixed references to external modules (e.g. '@serve.zone/interfaces', '@push.rocks/*', etc.) to reflect the updated paths.
|
||||
|
||||
## 2025-03-15 - 2.2.0 - feat(plugins)
|
||||
Add smartproxy support by including the @push.rocks/smartproxy dependency and exporting it in the plugins module.
|
||||
|
||||
- Added '@push.rocks/smartproxy' dependency version '^4.1.0' to package.json
|
||||
- Updated ts/plugins.ts to export the smartproxy module alongside other push.rocks modules
|
||||
|
||||
## 2025-03-15 - 2.1.0 - feat(MTA)
|
||||
Update readme with detailed Mail Transfer Agent usage and examples
|
||||
|
||||
- Added a comprehensive MTA section with usage examples including SMTP server setup, DKIM signing/verification, SPF/DMARC support, and API integration
|
||||
- Expanded the conclusion to highlight MTA capabilities alongside email, SMS, letter, and AI services
|
||||
|
||||
## 2025-03-15 - 2.0.0 - BREAKING CHANGE(platformservice)
|
||||
Remove deprecated AIBridge module and update email service to use the MTA connector; update dependency versions and adjust build scripts in package.json.
|
||||
|
||||
- Completely remove the aibridge module files (aibridge.classes.aibridge.ts, aibridge.classes.aibridgedb.ts, aibridge.classes.openaibridge.ts, aibridge.paths.ts, aibridge.plugins.ts, and index.ts) as they are no longer needed.
|
||||
- Switch the email service from using MailgunConnector to the new MTA connector for sending emails.
|
||||
- Update dependency versions for @serve.zone/interfaces, @tsclass/tsclass, letterxpress, and uuid in package.json.
|
||||
- Enhance the build script in package.json and add pnpm configuration.
|
||||
|
||||
## 2025-03-15 - 1.1.2 - fix(mta)
|
||||
Expose HttpResponse.statusCode and add explicit generic type annotations in DNSManager cache retrieval
|
||||
|
||||
- Changed HttpResponse.statusCode from private to public to allow external access and inspection
|
||||
- Added explicit generic type parameters in getFromCache calls for lookupMx and lookupTxt to enhance type safety
|
||||
|
||||
## 2025-03-15 - 1.1.1 - fix(paths)
|
||||
Update directory paths to use a dedicated 'data' directory and add ensureDirectories function for proper directory creation.
|
||||
|
||||
- Refactored ts/paths.ts to define a base data directory using process.cwd().
|
||||
- Reorganized MTA directories (keys, dns, emails sent/received/failed, logs) under the data directory.
|
||||
- Added ensureDirectories function to create missing directories at runtime.
|
||||
|
||||
## 2025-03-15 - 1.1.1 - fix(mta)
|
||||
Refactor API Manager and DKIMCreator: remove Express dependency in favor of Node's native HTTP server, add an HttpResponse helper to improve request handling, update path and authentication logic, and expose previously private DKIMCreator methods for API access.
|
||||
|
||||
- Replaced Express-based middleware with native HTTP server handling, including request body parsing and CORS headers.
|
||||
- Introduced an HttpResponse helper class to standardize response writing.
|
||||
- Updated route matching, parameter extraction, and error handling within the API Manager.
|
||||
- Modified DKIMCreator methods (createDKIMKeys, storeDKIMKeys, createAndStoreDKIMKeys, and getDNSRecordForDomain) from private to public for better API accessibility.
|
||||
- Updated plugin imports to include the native HTTP module.
|
||||
|
||||
## 2025-03-15 - 1.1.0 - feat(mta)
|
||||
Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes
|
||||
|
||||
- Introduce a state machine (SmtpState) and session management in the SMTP server to replace legacy buffering
|
||||
- Refactor DNSManager with caching and improved SPF, DKIM, and DMARC verification methods
|
||||
- Update Email class to support multiple recipients, CC, BCC with input sanitization and validation
|
||||
- Add detailed logging, TLS upgrade handling, and error-based retry logic in EmailSendJob
|
||||
- Implement a new API Manager with typed routes for sending emails, DKIM key generation, domain verification, and statistics
|
||||
- Integrate certificate provisioning with auto-renewal and TLS options in the MTA service configuration
|
||||
|
||||
## 2024-05-11 - 1.0.10 to 1.0.8 - core
|
||||
## 2025-05-04 - 1.0.10 to 1.0.8 - core
|
||||
Applied core fixes across several versions on this day.
|
||||
|
||||
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
|
||||
@@ -135,4 +338,4 @@ Applied a core fix.
|
||||
- Fixed core functionality for version 1.0.1
|
||||
|
||||
–––––––––––––––––––––––
|
||||
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain “1.0.x” commits) have been omitted from individual entries and are implicitly included in the version ranges above.
|
||||
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
|
||||
4
cli.child.js
Normal file
4
cli.child.js
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.CLI_CALL = 'true';
|
||||
import * as cliTool from './ts/index.js';
|
||||
cliTool.runCli();
|
||||
121
html/index.html
Normal file
121
html/index.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!--gitzone default-->
|
||||
<!-- made by Lossless GmbH -->
|
||||
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--Lets set some basic meta tags-->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||
/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!--Lets make sure we recognize this as an PWA-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||
|
||||
<!--Lets load standard fonts-->
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||
|
||||
|
||||
<!--Lets avoid a rescaling flicker due to default body margins-->
|
||||
<style>
|
||||
html {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
position: relative;
|
||||
background: #000;
|
||||
margin: 0px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
projectVersion = '';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<style>
|
||||
body {
|
||||
background: #303f9f;
|
||||
font-family: Inter, Roboto, sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-top: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
margin: auto;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
background: #4357d9;
|
||||
}
|
||||
.contentHeader {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 25px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.footer {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<div class="logo">
|
||||
<img src="https://assetbroker.lossless.one/brandfiles/lossless/svg-minimal-bright.svg" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="contentHeader">We need JavaScript to run properly!</div>
|
||||
<div class="content">
|
||||
This site is being built using lit-element (made by Google). This technology works with
|
||||
JavaScript. Subsequently this website does not work as intended by Lossless GmbH without
|
||||
JavaScript.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="https://lossless.gmbh">Legal Info</a> |
|
||||
<a href="https://lossless.gmbh/privacy">Privacy Policy</a>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="text/javascript" async defer>
|
||||
window.revenueEnabled = true;
|
||||
const runRevenueCheck = async () => {
|
||||
var e = document.createElement('div');
|
||||
e.id = '476kjuhzgtr764';
|
||||
e.style.display = 'none';
|
||||
document.body.appendChild(e);
|
||||
if (document.getElementById('476kjuhzgtr764')) {
|
||||
window.revenueEnabled = true;
|
||||
} else {
|
||||
window.revenueEnabled = false;
|
||||
}
|
||||
console.log(`revenue enabled: ${window.revenueEnabled}`);
|
||||
};
|
||||
|
||||
runRevenueCheck();
|
||||
</script>
|
||||
</body>
|
||||
<script defer type="module" src="/bundle.js"></script>
|
||||
</html>
|
||||
@@ -1,12 +1,35 @@
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
"watchers": [
|
||||
{
|
||||
"name": "dcrouter-dev",
|
||||
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
|
||||
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
"runOnStart": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
{
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"gitzone": {
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "platformservice",
|
||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
||||
"npmPackagename": "@serve.zone/platformservice",
|
||||
"gitrepo": "dcrouter",
|
||||
"description": "A traffic router intended to be gating your datacenter.",
|
||||
"npmPackagename": "@serve.zone/dcrouter",
|
||||
"license": "MIT",
|
||||
"projectDomain": "serve.zone",
|
||||
"keywords": [
|
||||
@@ -17,8 +40,7 @@
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"platform service",
|
||||
"mailgun integration",
|
||||
"traffic router",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
@@ -36,7 +58,7 @@
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
|
||||
102
package.json
102
package.json
@@ -1,56 +1,64 @@
|
||||
{
|
||||
"name": "@serve.zone/platformservice",
|
||||
"private": true,
|
||||
"version": "2.4.2",
|
||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "5.0.1",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js",
|
||||
"./interfaces": "./dist_ts_interfaces/index.js"
|
||||
},
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/)",
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
||||
"localPublish": ""
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
"bundle": "(tsbundle)",
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.3.2",
|
||||
"@git.zone/tsrun": "^1.2.8",
|
||||
"@git.zone/tstest": "^1.0.88",
|
||||
"@git.zone/tswatch": "^2.0.1",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^22.15.14"
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@types/node": "^25.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.0.19",
|
||||
"@api.global/typedserver": "^3.0.74",
|
||||
"@api.global/typedsocket": "^3.0.0",
|
||||
"@apiclient.xyz/cloudflare": "^6.4.1",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@push.rocks/qenv": "^6.1.0",
|
||||
"@push.rocks/smartacme": "^7.3.3",
|
||||
"@push.rocks/smartdata": "^5.15.1",
|
||||
"@push.rocks/smartdns": "^6.2.2",
|
||||
"@push.rocks/smartfile": "^11.0.4",
|
||||
"@push.rocks/smartlog": "^3.0.3",
|
||||
"@push.rocks/smartmail": "^2.1.0",
|
||||
"@push.rocks/smartpath": "^5.0.5",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartproxy": "^10.2.0",
|
||||
"@push.rocks/smartrequest": "^2.1.0",
|
||||
"@push.rocks/smartrule": "^2.0.1",
|
||||
"@api.global/typedrequest": "^3.2.6",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.42.0",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^8.0.0",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdns": "^7.8.0",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartmetrics": "^2.0.10",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartmta": "^5.2.1",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^23.1.2",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.0",
|
||||
"@serve.zone/interfaces": "^5.0.4",
|
||||
"@tsclass/tsclass": "^9.2.0",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"ip": "^2.0.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"mailauth": "^4.8.4",
|
||||
"mailparser": "^3.6.9",
|
||||
"uuid": "^11.1.0"
|
||||
"@push.rocks/smartstate": "^2.0.30",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"lru-cache": "^11.2.6",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
@@ -60,8 +68,7 @@
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"platform service",
|
||||
"mailgun integration",
|
||||
"mail router",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
@@ -72,7 +79,12 @@
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management"
|
||||
"DNS management",
|
||||
"RADIUS",
|
||||
"AAA",
|
||||
"network authentication",
|
||||
"VLAN assignment",
|
||||
"MAC authentication"
|
||||
],
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -81,5 +93,5 @@
|
||||
"puppeteer"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
"packageManager": "pnpm@10.11.0"
|
||||
}
|
||||
|
||||
9380
pnpm-lock.yaml
generated
9380
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
773
readme.hints.md
773
readme.hints.md
@@ -0,0 +1,773 @@
|
||||
# Implementation Hints and Learnings
|
||||
|
||||
## smartmta Migration (2026-02-11)
|
||||
|
||||
### Overview
|
||||
dcrouter's custom MTA code (~27,149 lines / 68 files in `ts/mail/` + `ts/deliverability/`) has been replaced with `@push.rocks/smartmta` v5.2.1, a TypeScript+Rust hybrid MTA. dcrouter is now an orchestrator that wires together SmartProxy, smartmta, smartdns, smartradius, and OpsServer.
|
||||
|
||||
### Architecture
|
||||
- **No socket-handler mode** — smartmta's Rust SMTP server binds its own ports directly
|
||||
- **SmartProxy forward mode only** — external email ports forwarded to internal ports where smartmta listens
|
||||
- Email traffic flow: External Port → SmartProxy → Internal Port → smartmta UnifiedEmailServer
|
||||
|
||||
### Key API Differences (smartmta vs old custom MTA)
|
||||
- `updateEmailRoutes()` instead of `updateRoutes()`
|
||||
- `dkimCreator` is public (no need for `(this.emailServer as any).dkimCreator`)
|
||||
- `bounceManager` is private, but exposed via public methods:
|
||||
- `emailServer.getSuppressionList()`
|
||||
- `emailServer.getHardBouncedAddresses()`
|
||||
- `emailServer.getBounceHistory(email)`
|
||||
- `emailServer.removeFromSuppressionList(email)`
|
||||
- `Email` class imported from `@push.rocks/smartmta`
|
||||
- `IAttachment` type accessed via `Core` namespace: `import { type Core } from '@push.rocks/smartmta'; type IAttachment = Core.IAttachment;`
|
||||
|
||||
### Deleted Directories
|
||||
- `ts/mail/` (60 files) — replaced by smartmta
|
||||
- `ts/deliverability/` (3 files) — IPWarmupManager/SenderReputationMonitor will move to smartmta
|
||||
- `ts/errors/email.errors.ts`, `ts/errors/mta.errors.ts` — smartmta has its own errors
|
||||
- `ts/cache/documents/classes.cached.bounce.ts`, `classes.cached.suppression.ts`, `classes.cached.dkim.ts` — smartmta handles its own persistence
|
||||
|
||||
### Remaining Cache Documents
|
||||
- `CachedEmail` — kept (dcrouter-level queue persistence)
|
||||
- `CachedIPReputation` — kept (dcrouter-level IP reputation caching)
|
||||
|
||||
### Dependencies Removed
|
||||
mailauth, mailparser, @types/mailparser, ip, @push.rocks/smartmail, @push.rocks/smartrule, node-forge
|
||||
|
||||
### Pre-existing Test Failures (not caused by migration)
|
||||
- `test/test.jwt-auth.ts` — `response.text is not a function` (webrequest compatibility issue)
|
||||
- `test/test.opsserver-api.ts` — same webrequest issue, timeouts
|
||||
|
||||
### smartmta Location
|
||||
Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt`
|
||||
|
||||
## Dependency Upgrade (2026-02-11)
|
||||
|
||||
### SmartProxy v23.1.2 Route Validation
|
||||
- SmartProxy 23.1.2 enforces stricter route validation
|
||||
- Forward actions MUST use `targets` (array) instead of `target` (singular)
|
||||
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid `/etc/dcrouter` permission errors
|
||||
|
||||
```typescript
|
||||
// WRONG - will fail validation
|
||||
action: { type: 'forward', target: { host: 'localhost', port: 10025 } }
|
||||
|
||||
// CORRECT
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 10025 }] }
|
||||
```
|
||||
|
||||
**Files Fixed:**
|
||||
- `ts/classes.dcrouter.ts` - `generateEmailRoutes()` method
|
||||
- `test/test.dcrouter.email.ts` - Updated assertions and added `cacheConfig: { enabled: false }`
|
||||
|
||||
## Dependency Upgrade (2026-02-10)
|
||||
|
||||
### SmartProxy v23.1.0 Upgrade
|
||||
- `@push.rocks/smartproxy`: 22.4.2 → 23.1.0
|
||||
|
||||
**Key Changes:**
|
||||
- Rust-based proxy components for improved performance
|
||||
- Rust binary runs as separate process via IPC
|
||||
- `getStatistics()` now returns `Promise<any>` (was synchronous)
|
||||
- nftables-proxy removed (not used by dcrouter)
|
||||
|
||||
**Code Changes Required:**
|
||||
```typescript
|
||||
// Old (synchronous)
|
||||
const proxyStats = this.dcRouter.smartProxy.getStatistics();
|
||||
|
||||
// New (async)
|
||||
const proxyStats = await this.dcRouter.smartProxy.getStatistics();
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
- `ts/monitoring/classes.metricsmanager.ts` - Added `await` to `getStatistics()` call
|
||||
|
||||
## Dependency Upgrade (2026-02-01)
|
||||
|
||||
### Major Upgrades Completed
|
||||
- `@api.global/typedserver`: 3.0.80 → 8.3.0
|
||||
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
|
||||
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
|
||||
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
|
||||
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
|
||||
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
|
||||
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
|
||||
- `uuid`: 11.1.0 → 13.0.0
|
||||
|
||||
### Breaking Changes Fixed
|
||||
|
||||
1. **SmartProxy v22**: `target` → `targets` (array)
|
||||
```typescript
|
||||
// Old
|
||||
action: { type: 'forward', target: { host: 'x', port: 25 } }
|
||||
// New
|
||||
action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
|
||||
```
|
||||
|
||||
2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
|
||||
```typescript
|
||||
// Old
|
||||
const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
|
||||
const json = resp.body;
|
||||
// New
|
||||
const resp = await plugins.smartrequest.SmartRequest.create()...post();
|
||||
const json = await resp.json();
|
||||
```
|
||||
|
||||
3. **dees-catalog v3**: Icon naming changed to library-prefixed format
|
||||
```typescript
|
||||
// Old (deprecated but supported)
|
||||
<dees-icon iconFA="check"></dees-icon>
|
||||
// New
|
||||
<dees-icon icon="fa:check"></dees-icon>
|
||||
<dees-icon icon="lucide:menu"></dees-icon>
|
||||
```
|
||||
|
||||
### TC39 Decorators
|
||||
- ts_web components updated to use `accessor` keyword for `@state()` decorators
|
||||
- Required for TC39 standard decorator support
|
||||
|
||||
### tswatch Configuration
|
||||
The project now uses tswatch for development:
|
||||
```bash
|
||||
pnpm run watch
|
||||
```
|
||||
Configuration in `npmextra.json`:
|
||||
```json
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
"watchers": [{
|
||||
"name": "dcrouter-dev",
|
||||
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
|
||||
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
"runOnStart": true
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## RADIUS Server Integration (2026-02-01)
|
||||
|
||||
### Overview
|
||||
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.
|
||||
|
||||
### Key Features
|
||||
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
|
||||
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
|
||||
- **RADIUS Accounting** - Track sessions, data usage, and billing
|
||||
|
||||
### Configuration Example
|
||||
```typescript
|
||||
const dcRouter = new DcRouter({
|
||||
radiusConfig: {
|
||||
authPort: 1812, // Authentication port (default)
|
||||
acctPort: 1813, // Accounting port (default)
|
||||
clients: [
|
||||
{
|
||||
name: 'switch-1',
|
||||
ipRange: '192.168.1.0/24',
|
||||
secret: 'shared-secret',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
vlanAssignment: {
|
||||
defaultVlan: 100, // VLAN for unknown MACs
|
||||
allowUnknownMacs: true,
|
||||
mappings: [
|
||||
{ mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
|
||||
{ mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern
|
||||
]
|
||||
},
|
||||
accounting: {
|
||||
enabled: true,
|
||||
retentionDays: 30
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Components
|
||||
- `RadiusServer` - Main server wrapping smartradius
|
||||
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
|
||||
- `AccountingManager` - Session tracking and billing data
|
||||
|
||||
### OpsServer API Endpoints
|
||||
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
|
||||
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
|
||||
- `testVlanAssignment` - Test what VLAN a MAC would get
|
||||
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
|
||||
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics
|
||||
|
||||
### Files
|
||||
- `ts/radius/` - RADIUS module
|
||||
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
|
||||
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces
|
||||
|
||||
## Test Fix: test.dcrouter.email.ts (2026-02-01)
|
||||
|
||||
### Issue
|
||||
The test `DcRouter class - Custom email storage path` was failing with "domainConfigs is not iterable".
|
||||
|
||||
### Root Cause
|
||||
The test was using outdated email config properties:
|
||||
- Used `domainRules: []` (non-existent property)
|
||||
- Used `defaultMode` (non-existent property)
|
||||
- Missing required `domains: []` property
|
||||
- Missing required `routes: []` property
|
||||
- Referenced `router.unifiedEmailServer` instead of `router.emailServer`
|
||||
|
||||
### Fix
|
||||
Updated the test to use the correct `IUnifiedEmailServerOptions` interface properties:
|
||||
```typescript
|
||||
const emailConfig: IEmailConfig = {
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [], // Required: domain configurations
|
||||
routes: [] // Required: email routing rules
|
||||
};
|
||||
```
|
||||
|
||||
And fixed the property name:
|
||||
```typescript
|
||||
expect(router.emailServer).toBeTruthy(); // Not unifiedEmailServer
|
||||
```
|
||||
|
||||
### Key Learning
|
||||
When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests):
|
||||
- `domains: IEmailDomainConfig[]` is required (array of domain configs)
|
||||
- `routes: IEmailRoute[]` is required (email routing rules)
|
||||
- Access the email server via `dcRouter.emailServer` not `dcRouter.unifiedEmailServer`
|
||||
|
||||
## Network Metrics Implementation (2025-06-23)
|
||||
|
||||
### SmartProxy Metrics API Integration
|
||||
- Updated to use new SmartProxy metrics API (v19.6.7)
|
||||
- Use `getMetrics()` for detailed metrics with grouped methods:
|
||||
```typescript
|
||||
const metrics = smartProxy.getMetrics();
|
||||
metrics.connections.active() // Current active connections
|
||||
metrics.throughput.instant() // Real-time throughput {in, out}
|
||||
metrics.connections.topIPs(10) // Top 10 IPs by connection count
|
||||
```
|
||||
- Use `getStatistics()` for basic stats
|
||||
|
||||
### Network Traffic Display
|
||||
- All throughput values shown in bits per second (kbit/s, Mbit/s, Gbit/s)
|
||||
- Conversion: `bytesPerSecond * 8 / 1000000` for Mbps
|
||||
- Network graph shows separate lines for inbound (green) and outbound (purple)
|
||||
- Throughput tiles and graph use same data source for consistency
|
||||
|
||||
### Requests/sec vs Connections
|
||||
- Requests/sec shows HTTP request counts (derived from connections)
|
||||
- Single connection can handle multiple requests
|
||||
- Current implementation tracks connections, not individual requests
|
||||
- Trend line shows historical request counts, not throughput
|
||||
|
||||
## DKIM Implementation Status (2025-05-30)
|
||||
|
||||
**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`.
|
||||
|
||||
## SmartProxy Usage
|
||||
|
||||
### New Route-Based Architecture (v18+)
|
||||
- SmartProxy now uses a route-based configuration system
|
||||
- Routes define match criteria and actions instead of simple port-to-port forwarding
|
||||
- All traffic types (HTTP, HTTPS, TCP, WebSocket) are configured through routes
|
||||
|
||||
```typescript
|
||||
// NEW: Route-based SmartProxy configuration
|
||||
const smartProxy = new plugins.smartproxy.SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
name: 'https-traffic',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: ['example.com', '*.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'backend.server.com',
|
||||
port: 8080
|
||||
}
|
||||
},
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
],
|
||||
defaults: {
|
||||
target: {
|
||||
host: 'fallback.server.com',
|
||||
port: 8080
|
||||
}
|
||||
},
|
||||
acme: {
|
||||
accountEmail: 'admin@example.com',
|
||||
enabled: true,
|
||||
useProduction: true
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Migration from Old to New
|
||||
```typescript
|
||||
// OLD configuration style (deprecated)
|
||||
{
|
||||
fromPort: 443,
|
||||
toPort: 8080,
|
||||
targetIP: 'backend.server.com',
|
||||
domainConfigs: [...]
|
||||
}
|
||||
|
||||
// NEW route-based style
|
||||
{
|
||||
routes: [{
|
||||
name: 'main-route',
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'backend.server.com', port: 8080 }
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Component Usage
|
||||
- Use SmartProxy components directly instead of creating your own wrappers
|
||||
- SmartProxy already includes Port80Handler and NetworkProxy functionality
|
||||
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
|
||||
|
||||
### Certificate Management
|
||||
- SmartProxy has built-in ACME certificate management
|
||||
- Configure it in the `acme` property of SmartProxy options
|
||||
- Use `accountEmail` (not `email`) for the ACME contact email
|
||||
- SmartProxy handles both HTTP-01 challenges and certificate application automatically
|
||||
|
||||
## qenv Usage
|
||||
|
||||
### Direct Usage
|
||||
- Use qenv directly instead of creating environment variable wrappers
|
||||
- Instantiate qenv with appropriate basePath and nogitPath:
|
||||
|
||||
```typescript
|
||||
const qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
|
||||
```
|
||||
|
||||
## TypeScript Interfaces
|
||||
|
||||
### SmartProxy Interfaces
|
||||
- Always check the interfaces from the node_modules to ensure correct property names
|
||||
- Important interfaces for the new architecture:
|
||||
- `ISmartProxyOptions`: Main configuration with `routes` array
|
||||
- `IRouteConfig`: Individual route configuration
|
||||
- `IRouteMatch`: Match criteria for routes
|
||||
- `IRouteTarget`: Target configuration for forwarding
|
||||
- `IAcmeOptions`: ACME certificate configuration
|
||||
- `TTlsMode`: TLS handling modes ('passthrough' | 'terminate' | 'terminate-and-reencrypt')
|
||||
|
||||
### New Route Configuration
|
||||
```typescript
|
||||
interface IRouteConfig {
|
||||
name: string;
|
||||
match: {
|
||||
ports: number | number[];
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
headers?: Record<string, string | RegExp>;
|
||||
};
|
||||
action: {
|
||||
type: 'forward' | 'redirect' | 'block' | 'static';
|
||||
target?: {
|
||||
host: string | string[] | ((context) => string);
|
||||
port: number | 'preserve' | ((context) => number);
|
||||
};
|
||||
};
|
||||
tls?: {
|
||||
mode: TTlsMode;
|
||||
certificate?: 'auto' | { key: string; cert: string; };
|
||||
};
|
||||
security?: {
|
||||
authentication?: IRouteAuthentication;
|
||||
rateLimit?: IRouteRateLimit;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Required Properties
|
||||
- For `ISmartProxyOptions`, `routes` array is the main configuration
|
||||
- For `IAcmeOptions`, use `accountEmail` for the contact email
|
||||
- Routes must have `name`, `match`, and `action` properties
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Structure
|
||||
- Follow the project's test structure, using `@push.rocks/tapbundle`
|
||||
- Use `expect(value).toEqual(expected)` for equality checks
|
||||
- Use `expect(value).toBeTruthy()` for boolean assertions
|
||||
|
||||
```typescript
|
||||
tap.test('test description', async () => {
|
||||
const result = someFunction();
|
||||
expect(result.property).toEqual('expected value');
|
||||
expect(result.valid).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
- Include a cleanup test to ensure proper test resource handling
|
||||
- Add a `stop` test to forcefully end the test when needed:
|
||||
|
||||
```typescript
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### Simplicity
|
||||
- Prefer direct usage of libraries instead of creating wrappers
|
||||
- Don't reinvent functionality that already exists in dependencies
|
||||
- Keep interfaces clean and focused, avoiding unnecessary abstraction layers
|
||||
|
||||
### Component Integration
|
||||
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
|
||||
- Use parallel operations for performance (like in the `stop()` method)
|
||||
- Separate concerns clearly (HTTP handling vs. SMTP handling)
|
||||
|
||||
## Email Integration with SmartProxy
|
||||
|
||||
### Architecture (Post-Migration)
|
||||
- Email traffic is routed through SmartProxy using automatic route generation
|
||||
- smartmta's UnifiedEmailServer runs on internal ports and receives forwarded traffic from SmartProxy
|
||||
- SmartProxy handles external ports (25, 587, 465) and forwards to internal ports
|
||||
- smartmta's Rust SMTP bridge handles SMTP protocol processing
|
||||
|
||||
### Port Mapping
|
||||
- External port 25 → Internal port 10025 (SMTP)
|
||||
- External port 587 → Internal port 10587 (Submission)
|
||||
- External port 465 → Internal port 10465 (SMTPS)
|
||||
|
||||
### TLS Handling
|
||||
- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta)
|
||||
- Port 465: Use 'terminate' mode (SmartProxy handles TLS termination)
|
||||
|
||||
## SmartMetrics Integration (2025-06-12) - COMPLETED
|
||||
|
||||
### Overview
|
||||
Fixed the UI metrics display to show accurate CPU and memory data from SmartMetrics.
|
||||
|
||||
### Key Findings
|
||||
1. **CPU Metrics:**
|
||||
- SmartMetrics provides `cpuUsageText` as a string percentage
|
||||
- MetricsManager parses it as `cpuUsage.user` (system is always 0)
|
||||
- UI was incorrectly dividing by 2, showing half the actual CPU usage
|
||||
|
||||
2. **Memory Metrics:**
|
||||
- SmartMetrics calculates `maxMemoryMB` as minimum of:
|
||||
- V8 heap size limit
|
||||
- System total memory
|
||||
- Docker memory limit (if available)
|
||||
- Provides `memoryUsageBytes` (total process memory including children)
|
||||
- Provides `memoryPercentage` (pre-calculated percentage)
|
||||
- UI was only showing heap usage, missing actual memory constraints
|
||||
|
||||
### Changes Made
|
||||
1. **MetricsManager Enhanced:**
|
||||
- Added `maxMemoryMB` from SmartMetrics instance
|
||||
- Added `actualUsageBytes` from SmartMetrics data
|
||||
- Added `actualUsagePercentage` from SmartMetrics data
|
||||
- Kept existing memory fields for compatibility
|
||||
|
||||
2. **Interface Updated:**
|
||||
- Added optional fields to `IServerStats.memoryUsage`
|
||||
- Fields are optional to maintain backward compatibility
|
||||
|
||||
3. **UI Fixed:**
|
||||
- Removed incorrect CPU division by 2
|
||||
- Uses `actualUsagePercentage` when available (falls back to heap percentage)
|
||||
- Shows actual memory usage vs max memory limit (not just heap)
|
||||
|
||||
### Result
|
||||
- CPU now shows accurate usage percentage
|
||||
- Memory shows percentage of actual constraints (Docker/system/V8 limits)
|
||||
- Better monitoring for containerized environments
|
||||
|
||||
## Network UI Implementation (2025-06-20) - COMPLETED
|
||||
|
||||
### Overview
|
||||
Revamped the Network UI to display real network data from SmartProxy instead of mock data.
|
||||
|
||||
### Architecture
|
||||
1. **MetricsManager Integration:**
|
||||
- Already integrates with SmartProxy via `dcRouter.smartProxy.getStats()`
|
||||
- Extended with `getNetworkStats()` method to expose unused metrics:
|
||||
- `getConnectionsByIP()` - Connection counts by IP address
|
||||
- `getThroughputRate()` - Real-time bandwidth rates (bytes/second)
|
||||
- `getTopIPs()` - Top connecting IPs sorted by connection count
|
||||
- Note: SmartProxy base interface doesn't include all methods, manual implementation required
|
||||
|
||||
2. **Existing Infrastructure Leveraged:**
|
||||
- `getActiveConnections` endpoint already exists in security.handler.ts
|
||||
- Enhanced to include real SmartProxy data via MetricsManager
|
||||
- IConnectionInfo interface already supports network data structures
|
||||
|
||||
3. **State Management:**
|
||||
- Added `INetworkState` interface following existing patterns
|
||||
- Created `networkStatePart` with connections, throughput, and IP data
|
||||
- Integrated with existing auto-refresh mechanism
|
||||
|
||||
4. **UI Changes (Minimal):**
|
||||
- Removed `generateMockData()` method and all mock generation
|
||||
- Connected to real `networkStatePart` state
|
||||
- Added `renderTopIPs()` section to display top connected IPs
|
||||
- Updated traffic chart to show real request data
|
||||
- Kept all existing UI components (DeesTable, DeesChartArea)
|
||||
|
||||
### Implementation Details
|
||||
1. **Data Transformation:**
|
||||
- Converts IConnectionInfo[] to INetworkRequest[] for table display
|
||||
- Calculates traffic buckets based on selected time range
|
||||
- Maps connection data to chart-compatible format
|
||||
|
||||
2. **Real Metrics Displayed:**
|
||||
- Active connections count (from server stats)
|
||||
- Requests per second (calculated from recent connections)
|
||||
- Throughput rates (currently showing 0 until SmartProxy exposes rates)
|
||||
- Top IPs with connection counts and percentages
|
||||
|
||||
3. **TypeScript Fixes:**
|
||||
- SmartProxy methods like `getThroughputRate()` not in base interface
|
||||
- Implemented manual fallbacks for missing methods
|
||||
- Fixed `publicIpv4` → `publicIp` property name
|
||||
|
||||
### Result
|
||||
- Network view now shows real connection activity
|
||||
- Auto-refreshes with other stats every second
|
||||
- Displays actual IPs and connection counts
|
||||
- No more mock/demo data
|
||||
- Minimal code changes (streamlined approach)
|
||||
|
||||
### Throughput Data Fix (2025-06-20)
|
||||
The throughput was showing 0 because:
|
||||
1. MetricsManager was hardcoding throughputRate to 0, assuming the method didn't exist
|
||||
2. SmartProxy's `getStats()` returns `IProxyStats` interface, but the actual object (`MetricsCollector`) implements `IProxyStatsExtended`
|
||||
3. `getThroughputRate()` only exists in the extended interface
|
||||
|
||||
**Solution implemented:**
|
||||
1. Updated MetricsManager to check if methods exist at runtime and call them
|
||||
2. Added property name mapping (`bytesInPerSec` → `bytesInPerSecond`)
|
||||
3. Created new `getNetworkStats` endpoint in security.handler.ts
|
||||
4. Updated frontend to call the new endpoint for complete network metrics
|
||||
|
||||
The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.
|
||||
|
||||
## Email Operations Dashboard (2026-02-01)
|
||||
|
||||
### Overview
|
||||
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.
|
||||
|
||||
### New Files Created
|
||||
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
|
||||
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations
|
||||
|
||||
### Key Interfaces
|
||||
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
|
||||
- `IReq_GetSentEmails` - Fetch delivered emails
|
||||
- `IReq_GetFailedEmails` - Fetch failed emails
|
||||
- `IReq_ResendEmail` - Re-queue a failed email for retry
|
||||
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
|
||||
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
|
||||
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list
|
||||
|
||||
### UI Changes (ops-view-emails.ts)
|
||||
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
|
||||
- **Queued**: Emails pending delivery
|
||||
- **Sent**: Successfully delivered emails
|
||||
- **Failed**: Failed emails with resend capability
|
||||
- **Security**: Security incidents from SecurityLogger
|
||||
- Removed `generateMockEmails()` method
|
||||
- Added state management via `emailOpsStatePart` in appstate.ts
|
||||
- Added resend button for failed emails
|
||||
- Added security incident detail view
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
```
|
||||
|
||||
### Backend Data Access
|
||||
The handler accesses data from:
|
||||
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
|
||||
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
|
||||
- `emailServer.bounceManager` - Bounce records and suppression list
|
||||
|
||||
## OpsServer UI Fixes (2026-02-02)
|
||||
|
||||
### Configuration Page Fix
|
||||
The configuration page had field name mismatches between frontend and backend:
|
||||
- Frontend expected `server` and `storage` sections
|
||||
- Backend returns `proxy` section (not `server`)
|
||||
- Backend has no `storage` section
|
||||
|
||||
**Fix**: Updated `ops-view-config.ts` to use correct section names:
|
||||
- `proxy` instead of `server`
|
||||
- Removed non-existent `storage` section
|
||||
- Added optional chaining (`?.`) for safety
|
||||
|
||||
### Auth Persistence Fix
|
||||
Login state was using `'soft'` mode in Smartstate which is memory-only:
|
||||
- User login was lost on page refresh
|
||||
- State reset to logged out after browser restart
|
||||
|
||||
**Changes**:
|
||||
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
|
||||
- Now uses IndexedDB to persist across browser sessions
|
||||
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
|
||||
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
|
||||
- Validates stored JWT hasn't expired before auto-logging in
|
||||
- Clears expired sessions and shows login form
|
||||
|
||||
## Config UI Read-Only Conversion (2026-02-03)
|
||||
|
||||
### Overview
|
||||
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
|
||||
- Removed `updateConfiguration` handler
|
||||
- Removed `updateConfiguration()` private method
|
||||
- Kept `getConfiguration` handler (read-only)
|
||||
|
||||
2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
|
||||
- Removed `IReq_UpdateConfiguration` interface
|
||||
- Kept `IReq_GetConfiguration` interface
|
||||
|
||||
3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
|
||||
- Removed `editingSection` and `editedConfig` state properties
|
||||
- Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
|
||||
- Removed Edit/Save/Cancel buttons
|
||||
- Removed warning banner about immediate changes
|
||||
- Enhanced read-only display with:
|
||||
- Status badges for boolean values (enabled/disabled)
|
||||
- Array display as pills/tags with counts
|
||||
- Section icons (mail, globe, network, shield)
|
||||
- Better formatting for numbers and byte sizes
|
||||
- Empty state handling ("Not configured", "None configured")
|
||||
- Info note explaining configuration is read-only
|
||||
|
||||
4. **State Management (`ts_web/appstate.ts`)**:
|
||||
- Removed `updateConfigurationAction`
|
||||
- Kept `fetchConfigurationAction` (read-only)
|
||||
|
||||
5. **Tests (`test/test.protected-endpoint.ts`)**:
|
||||
- Replaced `updateConfiguration` tests with `verifyIdentity` tests
|
||||
- Added test for read-only config access
|
||||
- Kept auth flow testing with different protected endpoint
|
||||
|
||||
6. **Documentation**:
|
||||
- `readme.md`: Updated API endpoints to show config as read-only
|
||||
- `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
|
||||
- `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table
|
||||
|
||||
### Visual Display Features
|
||||
- Boolean values shown as colored badges (green=enabled, red=disabled)
|
||||
- Arrays displayed as pills with count summaries
|
||||
- Section headers with relevant Lucide icons
|
||||
- Numbers formatted with locale separators
|
||||
- Byte sizes auto-formatted (B, KB, MB, GB)
|
||||
- Time values shown with "seconds" suffix
|
||||
- Nested objects with visual indentation
|
||||
|
||||
## Smartdata Cache System (2026-02-03)
|
||||
|
||||
### Overview
|
||||
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `/etc/dcrouter/tsmdb`.
|
||||
|
||||
### Technology Stack
|
||||
| Layer | Package | Purpose |
|
||||
|-------|---------|---------|
|
||||
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
|
||||
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |
|
||||
|
||||
### TC39 Decorators
|
||||
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
|
||||
- Removed `experimentalDecorators: true`
|
||||
- Removed `emitDecoratorMetadata: true`
|
||||
|
||||
This is required for smartdata v7+ compatibility.
|
||||
|
||||
### Cache Document Classes
|
||||
Located in `ts/cache/documents/`:
|
||||
|
||||
| Class | Purpose | Default TTL |
|
||||
|-------|---------|-------------|
|
||||
| `CachedEmail` | Email queue items | 30 days |
|
||||
| `CachedIPReputation` | IP reputation lookups | 24 hours |
|
||||
|
||||
Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those).
|
||||
|
||||
### Usage Pattern
|
||||
```typescript
|
||||
// Document classes use smartdata decorators
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
// ...
|
||||
}
|
||||
|
||||
// Query examples
|
||||
const email = await CachedEmail.getInstance({ id: 'abc123' });
|
||||
const pending = await CachedEmail.getInstances({ status: 'pending' });
|
||||
await email.save();
|
||||
await email.delete();
|
||||
```
|
||||
|
||||
### Configuration
|
||||
```typescript
|
||||
const dcRouter = new DcRouter({
|
||||
cacheConfig: {
|
||||
enabled: true,
|
||||
storagePath: '/etc/dcrouter/tsmdb',
|
||||
dbName: 'dcrouter',
|
||||
cleanupIntervalHours: 1,
|
||||
ttlConfig: {
|
||||
emails: 30, // days
|
||||
ipReputation: 1, // days
|
||||
bounces: 30, // days
|
||||
dkimKeys: 90, // days
|
||||
suppression: 30 // days
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Cache Cleaner
|
||||
- Runs hourly by default (configurable via `cleanupIntervalHours`)
|
||||
- Finds and deletes documents where `expiresAt < now()`
|
||||
- Uses smartdata's `getInstances()` + `delete()` pattern
|
||||
|
||||
### Key Files
|
||||
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
|
||||
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
|
||||
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
|
||||
- `ts/cache/documents/*.ts` - Document class definitions
|
||||
122
readme.plan.md
122
readme.plan.md
@@ -1,122 +0,0 @@
|
||||
# Plan for Further Enhancing the Email Stack
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
The platformservice now has a robust email system with:
|
||||
- Enhanced EmailValidator with comprehensive validation (format, MX, spam detection)
|
||||
- Improved TemplateManager with typed templates and variable substitution
|
||||
- Streamlined conversion between Email and Smartmail formats
|
||||
- Strong attachment handling
|
||||
- Comprehensive testing
|
||||
|
||||
## Identified Enhancement Opportunities
|
||||
|
||||
### 1. Performance Optimization
|
||||
|
||||
- [x] Replace setTimeout-based DNS cache with proper LRU cache implementation
|
||||
- [x] Implement rate limiting for outbound emails
|
||||
- [ ] Add bulk email handling with batching capabilities
|
||||
- [ ] Optimize template rendering for high-volume scenarios
|
||||
|
||||
### 2. Security Enhancements
|
||||
|
||||
- [x] Implement DMARC policy checking and enforcement
|
||||
- [x] Add SPF validation for incoming emails
|
||||
- [x] Enhance logging for security-related events
|
||||
- [x] Add IP reputation checking for inbound emails
|
||||
- [x] Implement content scanning for potentially malicious payloads
|
||||
|
||||
### 3. Deliverability Improvements
|
||||
|
||||
- [x] Implement bounce handling and feedback loop processing
|
||||
- [x] Add automated IP warmup capabilities
|
||||
- [x] Develop sender reputation monitoring
|
||||
- [ ] Create domain rotation for high-volume sending
|
||||
|
||||
### 4. Advanced Templating
|
||||
|
||||
- [ ] Add conditional logic in email templates
|
||||
- [ ] Support localization with i18n integration
|
||||
- [ ] Implement template versioning and A/B testing capabilities
|
||||
- [ ] Add rich media handling (responsive images, video thumbnails)
|
||||
|
||||
### 5. Analytics and Monitoring
|
||||
|
||||
- [ ] Implement delivery tracking and reporting
|
||||
- [ ] Add open and click tracking
|
||||
- [ ] Create dashboards for email performance
|
||||
- [ ] Set up alerts for delivery issues
|
||||
- [ ] Add spam complaint monitoring
|
||||
|
||||
### 6. Integration Enhancements
|
||||
|
||||
- [ ] Add webhook support for email events
|
||||
- [ ] Implement integration with popular ESPs as fallback providers
|
||||
- [ ] Add support for calendar invites and structured data
|
||||
- [ ] Create API for managing suppression lists
|
||||
|
||||
### 7. Testing and QA
|
||||
|
||||
- [ ] Implement email rendering tests across email clients
|
||||
- [ ] Add load testing for high-volume scenarios
|
||||
- [ ] Create end-to-end testing of complete email journeys
|
||||
- [ ] Add spam testing and deliverability scoring
|
||||
|
||||
## Implementation Progress
|
||||
|
||||
### Completed Enhancements
|
||||
|
||||
1. **Performance Optimization**
|
||||
- Replaced setTimeout-based DNS cache with LRU cache for more efficient and reliable caching
|
||||
- Implemented advanced rate limiting with token bucket algorithm for outbound emails
|
||||
|
||||
2. **Security Enhancements**
|
||||
- Added comprehensive security logging system for email-related security events
|
||||
- Created a centralized SecurityLogger with event categorization and filtering
|
||||
- Implemented DMARC policy checking and enforcement for improved email authentication
|
||||
- Added SPF validation for incoming emails with proper record parsing and verification
|
||||
- Implemented IP reputation checking for inbound emails with DNSBL integration
|
||||
- Added detection for suspicious IPs (proxies, VPNs, Tor exit nodes)
|
||||
- Implemented configurable throttling/rejection for low-reputation IPs
|
||||
- Implemented content scanning for malicious payloads with pattern matching
|
||||
- Added detection for phishing, spam, malware indicators, executable attachments
|
||||
- Created quarantine capabilities for suspicious emails with configurable thresholds
|
||||
- Implemented macro detection in Office document attachments
|
||||
|
||||
3. **Deliverability Improvements**
|
||||
- Implemented bounce handling with detection and categorization of different bounce types
|
||||
- Created suppression list management to prevent sending to known bad addresses
|
||||
- Added exponential backoff retry strategy for soft bounces
|
||||
- Implemented automated IP warmup capabilities:
|
||||
- Created configurable warmup stages with progressive volume increases
|
||||
- Added multiple allocation policies (balanced, round robin, dedicated domain)
|
||||
- Implemented daily and hourly sending limits with tracking
|
||||
- Added persistence for warmup state between service restarts
|
||||
- Developed comprehensive sender reputation monitoring:
|
||||
- Implemented tracking of key deliverability metrics (bounces, complaints, opens, etc.)
|
||||
- Added reputation scoring with multiple weighted components
|
||||
- Created blacklist monitoring integration
|
||||
- Implemented trend analysis for early detection of reputation issues
|
||||
- Added full event tracking for sent, delivered, bounced, and complaint events
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Continue with security enhancements:
|
||||
- ✅ Added IP reputation checking for inbound emails with DNS blacklist integration and caching
|
||||
- ✅ Implemented content scanning for potentially malicious payloads with pattern matching and threat scoring
|
||||
|
||||
2. Further deliverability improvements:
|
||||
- ✅ Added automated IP warmup capabilities with configurable stages and allocation policies
|
||||
- ✅ Developed sender reputation monitoring with bounce tracking and metric calculation
|
||||
|
||||
3. Implement analytics and monitoring to gain visibility into performance
|
||||
|
||||
Each enhancement is being implemented incrementally with comprehensive testing to ensure reliability and backward compatibility, while maintaining the clean separation of concerns established in the codebase.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- Improved deliverability rates (95%+ inbox placement)
|
||||
- Enhanced security with no vulnerabilities
|
||||
- Support for high volume sending (10,000+ emails per hour)
|
||||
- Rich analytics providing actionable insights
|
||||
- High template flexibility for marketing and transactional emails
|
||||
443
test/readme.md
Normal file
443
test/readme.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# DCRouter SMTP Test Suite
|
||||
|
||||
```
|
||||
test/
|
||||
├── readme.md # This file
|
||||
├── helpers/
|
||||
│ ├── server.loader.ts # SMTP server lifecycle management
|
||||
│ ├── utils.ts # Common test utilities
|
||||
│ └── smtp.client.ts # Test SMTP client utilities
|
||||
└── suite/
|
||||
├── smtpserver_commands/ # SMTP command tests (CMD)
|
||||
├── smtpserver_connection/ # Connection management tests (CM)
|
||||
├── smtpserver_edge-cases/ # Edge case tests (EDGE)
|
||||
├── smtpserver_email-processing/ # Email processing tests (EP)
|
||||
├── smtpserver_error-handling/ # Error handling tests (ERR)
|
||||
├── smtpserver_performance/ # Performance tests (PERF)
|
||||
├── smtpserver_reliability/ # Reliability tests (REL)
|
||||
├── smtpserver_rfc-compliance/ # RFC compliance tests (RFC)
|
||||
└── smtpserver_security/ # Security tests (SEC)
|
||||
```
|
||||
|
||||
## Test ID Convention
|
||||
|
||||
All test files follow a strict naming convention: `test.<category-id>.<description>.ts`
|
||||
|
||||
Examples:
|
||||
- `test.cmd-01.ehlo-command.ts` - EHLO command test
|
||||
- `test.cm-01.tls-connection.ts` - TLS connection test
|
||||
- `test.sec-01.authentication.ts` - Authentication test
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. Connection Management (CM)
|
||||
|
||||
Tests for validating SMTP connection handling, TLS support, and connection lifecycle management.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|-------|-------------------------------------------|----------|----------------|
|
||||
| CM-01 | TLS Connection Test | High | `suite/smtpserver_connection/test.cm-01.tls-connection.ts` |
|
||||
| CM-02 | Multiple Simultaneous Connections | High | `suite/smtpserver_connection/test.cm-02.multiple-connections.ts` |
|
||||
| CM-03 | Connection Timeout | High | `suite/smtpserver_connection/test.cm-03.connection-timeout.ts` |
|
||||
| CM-04 | Connection Limits | Medium | `suite/smtpserver_connection/test.cm-04.connection-limits.ts` |
|
||||
| CM-05 | Connection Rejection | Medium | `suite/smtpserver_connection/test.cm-05.connection-rejection.ts` |
|
||||
| CM-06 | STARTTLS Connection Upgrade | High | `suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts` |
|
||||
| CM-07 | Abrupt Client Disconnection | Medium | `suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts` |
|
||||
| CM-08 | TLS Version Compatibility | Medium | `suite/smtpserver_connection/test.cm-08.tls-versions.ts` |
|
||||
| CM-09 | TLS Cipher Configuration | Medium | `suite/smtpserver_connection/test.cm-09.tls-ciphers.ts` |
|
||||
| CM-10 | Plain Connection Test | Low | `suite/smtpserver_connection/test.cm-10.plain-connection.ts` |
|
||||
| CM-11 | TCP Keep-Alive Test | Low | `suite/smtpserver_connection/test.cm-11.keepalive.ts` |
|
||||
|
||||
### 2. SMTP Commands (CMD)
|
||||
|
||||
Tests for validating proper SMTP protocol command implementation.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CMD-01 | EHLO Command | High | `suite/smtpserver_commands/test.cmd-01.ehlo-command.ts` |
|
||||
| CMD-02 | MAIL FROM Command | High | `suite/smtpserver_commands/test.cmd-02.mail-from.ts` |
|
||||
| CMD-03 | RCPT TO Command | High | `suite/smtpserver_commands/test.cmd-03.rcpt-to.ts` |
|
||||
| CMD-04 | DATA Command | High | `suite/smtpserver_commands/test.cmd-04.data-command.ts` |
|
||||
| CMD-05 | NOOP Command | Medium | `suite/smtpserver_commands/test.cmd-05.noop-command.ts` |
|
||||
| CMD-06 | RSET Command | Medium | `suite/smtpserver_commands/test.cmd-06.rset-command.ts` |
|
||||
| CMD-07 | VRFY Command | Low | `suite/smtpserver_commands/test.cmd-07.vrfy-command.ts` |
|
||||
| CMD-08 | EXPN Command | Low | `suite/smtpserver_commands/test.cmd-08.expn-command.ts` |
|
||||
| CMD-09 | SIZE Extension | Medium | `suite/smtpserver_commands/test.cmd-09.size-extension.ts` |
|
||||
| CMD-10 | HELP Command | Low | `suite/smtpserver_commands/test.cmd-10.help-command.ts` |
|
||||
| CMD-11 | Command Pipelining | Medium | `suite/smtpserver_commands/test.cmd-11.command-pipelining.ts` |
|
||||
| CMD-12 | HELO Command | Low | `suite/smtpserver_commands/test.cmd-12.helo-command.ts` |
|
||||
| CMD-13 | QUIT Command | High | `suite/smtpserver_commands/test.cmd-13.quit-command.ts` |
|
||||
|
||||
### 3. Email Processing (EP)
|
||||
|
||||
Tests for validating email content handling, parsing, and delivery.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|-------|-------------------------------------------|----------|----------------|
|
||||
| EP-01 | Basic Email Sending | High | `suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts` |
|
||||
| EP-02 | Invalid Email Address Handling | High | `suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts` |
|
||||
| EP-03 | Multiple Recipients | Medium | `suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts` |
|
||||
| EP-04 | Large Email Handling | High | `suite/smtpserver_email-processing/test.ep-04.large-email.ts` |
|
||||
| EP-05 | MIME Handling | High | `suite/smtpserver_email-processing/test.ep-05.mime-handling.ts` |
|
||||
| EP-06 | Attachment Handling | Medium | `suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts` |
|
||||
| EP-07 | Special Character Handling | Medium | `suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts` |
|
||||
| EP-08 | Email Routing | High | `suite/smtpserver_email-processing/test.ep-08.email-routing.ts` |
|
||||
| EP-09 | Delivery Status Notifications | Medium | `suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts` |
|
||||
|
||||
### 4. Security (SEC)
|
||||
|
||||
Tests for validating security features and protections.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| SEC-01 | Authentication | High | `suite/smtpserver_security/test.sec-01.authentication.ts` |
|
||||
| SEC-02 | Authorization | High | `suite/smtpserver_security/test.sec-02.authorization.ts` |
|
||||
| SEC-03 | DKIM Processing | High | `suite/smtpserver_security/test.sec-03.dkim-processing.ts` |
|
||||
| SEC-04 | SPF Checking | High | `suite/smtpserver_security/test.sec-04.spf-checking.ts` |
|
||||
| SEC-05 | DMARC Policy Enforcement | Medium | `suite/smtpserver_security/test.sec-05.dmarc-policy.ts` |
|
||||
| SEC-06 | IP Reputation Checking | High | `suite/smtpserver_security/test.sec-06.ip-reputation.ts` |
|
||||
| SEC-07 | Content Scanning | Medium | `suite/smtpserver_security/test.sec-07.content-scanning.ts` |
|
||||
| SEC-08 | Rate Limiting | High | `suite/smtpserver_security/test.sec-08.rate-limiting.ts` |
|
||||
| SEC-09 | TLS Certificate Validation | High | `suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts` |
|
||||
| SEC-10 | Header Injection Prevention | High | `suite/smtpserver_security/test.sec-10.header-injection-prevention.ts` |
|
||||
| SEC-11 | Bounce Management | Medium | `suite/smtpserver_security/test.sec-11.bounce-management.ts` |
|
||||
|
||||
### 5. Error Handling (ERR)
|
||||
|
||||
Tests for validating proper error handling and recovery.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| ERR-01 | Syntax Error Handling | High | `suite/smtpserver_error-handling/test.err-01.syntax-errors.ts` |
|
||||
| ERR-02 | Invalid Sequence Handling | High | `suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts` |
|
||||
| ERR-03 | Temporary Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-03.temporary-failures.ts` |
|
||||
| ERR-04 | Permanent Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-04.permanent-failures.ts` |
|
||||
| ERR-05 | Resource Exhaustion Handling | High | `suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts` |
|
||||
| ERR-06 | Malformed MIME Handling | Medium | `suite/smtpserver_error-handling/test.err-06.malformed-mime.ts` |
|
||||
| ERR-07 | Exception Handling | High | `suite/smtpserver_error-handling/test.err-07.exception-handling.ts` |
|
||||
| ERR-08 | Error Logging | Medium | `suite/smtpserver_error-handling/test.err-08.error-logging.ts` |
|
||||
|
||||
### 6. Performance (PERF)
|
||||
|
||||
Tests for validating performance characteristics and benchmarks.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|------------------------------------------|----------|----------------|
|
||||
| PERF-01 | Throughput Testing | Medium | `suite/smtpserver_performance/test.perf-01.throughput.ts` |
|
||||
| PERF-02 | Concurrency Testing | High | `suite/smtpserver_performance/test.perf-02.concurrency.ts` |
|
||||
| PERF-03 | CPU Utilization | Medium | `suite/smtpserver_performance/test.perf-03.cpu-utilization.ts` |
|
||||
| PERF-04 | Memory Usage | Medium | `suite/smtpserver_performance/test.perf-04.memory-usage.ts` |
|
||||
| PERF-05 | Connection Processing Time | Medium | `suite/smtpserver_performance/test.perf-05.connection-processing-time.ts` |
|
||||
| PERF-06 | Message Processing Time | Medium | `suite/smtpserver_performance/test.perf-06.message-processing-time.ts` |
|
||||
| PERF-07 | Resource Cleanup | High | `suite/smtpserver_performance/test.perf-07.resource-cleanup.ts` |
|
||||
|
||||
### 7. Reliability (REL)
|
||||
|
||||
Tests for validating system reliability and stability.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| REL-01 | Long-Running Operation | High | `suite/smtpserver_reliability/test.rel-01.long-running-operation.ts` |
|
||||
| REL-02 | Restart Recovery | High | `suite/smtpserver_reliability/test.rel-02.restart-recovery.ts` |
|
||||
| REL-03 | Resource Leak Detection | High | `suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts` |
|
||||
| REL-04 | Error Recovery | High | `suite/smtpserver_reliability/test.rel-04.error-recovery.ts` |
|
||||
| REL-05 | DNS Resolution Failure Handling | Medium | `suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts` |
|
||||
| REL-06 | Network Interruption Handling | Medium | `suite/smtpserver_reliability/test.rel-06.network-interruption.ts` |
|
||||
|
||||
### 8. Edge Cases (EDGE)
|
||||
|
||||
Tests for validating handling of unusual or extreme scenarios.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| EDGE-01 | Very Large Email | Low | `suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts` |
|
||||
| EDGE-02 | Very Small Email | Low | `suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts` |
|
||||
| EDGE-03 | Invalid Character Handling | Medium | `suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts` |
|
||||
| EDGE-04 | Empty Commands | Low | `suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts` |
|
||||
| EDGE-05 | Extremely Long Lines | Medium | `suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts` |
|
||||
| EDGE-06 | Extremely Long Headers | Medium | `suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts` |
|
||||
| EDGE-07 | Unusual MIME Types | Low | `suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts` |
|
||||
| EDGE-08 | Nested MIME Structures | Low | `suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts` |
|
||||
|
||||
### 9. RFC Compliance (RFC)
|
||||
|
||||
Tests for validating compliance with SMTP-related RFCs.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| RFC-01 | RFC 5321 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts` |
|
||||
| RFC-02 | RFC 5322 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts` |
|
||||
| RFC-03 | RFC 7208 SPF Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts` |
|
||||
| RFC-04 | RFC 6376 DKIM Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts` |
|
||||
| RFC-05 | RFC 7489 DMARC Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts` |
|
||||
| RFC-06 | RFC 8314 TLS Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts` |
|
||||
| RFC-07 | RFC 3461 DSN Compliance | Low | `suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts` |
|
||||
|
||||
## SMTP Client Test Suite
|
||||
|
||||
The following test categories ensure our SMTP client is production-ready, RFC-compliant, and handles all real-world scenarios properly.
|
||||
|
||||
### Client Test Organization
|
||||
|
||||
```
|
||||
test/
|
||||
└── suite/
|
||||
├── smtpclient_connection/ # Client connection management tests (CCM)
|
||||
├── smtpclient_commands/ # Client command execution tests (CCMD)
|
||||
├── smtpclient_email-composition/ # Email composition tests (CEP)
|
||||
├── smtpclient_security/ # Client security tests (CSEC)
|
||||
├── smtpclient_error-handling/ # Client error handling tests (CERR)
|
||||
├── smtpclient_performance/ # Client performance tests (CPERF)
|
||||
├── smtpclient_reliability/ # Client reliability tests (CREL)
|
||||
├── smtpclient_edge-cases/ # Client edge case tests (CEDGE)
|
||||
└── smtpclient_rfc-compliance/ # Client RFC compliance tests (CRFC)
|
||||
```
|
||||
|
||||
### 10. Client Connection Management (CCM)
|
||||
|
||||
Tests for validating how the SMTP client establishes and manages connections to servers.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CCM-01 | Basic TCP Connection | High | `suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts` |
|
||||
| CCM-02 | TLS Connection Establishment | High | `suite/smtpclient_connection/test.ccm-02.tls-connection.ts` |
|
||||
| CCM-03 | STARTTLS Upgrade | High | `suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts` |
|
||||
| CCM-04 | Connection Pooling | High | `suite/smtpclient_connection/test.ccm-04.connection-pooling.ts` |
|
||||
| CCM-05 | Connection Reuse | Medium | `suite/smtpclient_connection/test.ccm-05.connection-reuse.ts` |
|
||||
| CCM-06 | Connection Timeout Handling | High | `suite/smtpclient_connection/test.ccm-06.connection-timeout.ts` |
|
||||
| CCM-07 | Automatic Reconnection | High | `suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts` |
|
||||
| CCM-08 | DNS Resolution & MX Records | High | `suite/smtpclient_connection/test.ccm-08.dns-mx-resolution.ts` |
|
||||
| CCM-09 | IPv4/IPv6 Dual Stack Support | Medium | `suite/smtpclient_connection/test.ccm-09.dual-stack-support.ts` |
|
||||
| CCM-10 | Proxy Support (SOCKS/HTTP) | Low | `suite/smtpclient_connection/test.ccm-10.proxy-support.ts` |
|
||||
| CCM-11 | Keep-Alive Management | Medium | `suite/smtpclient_connection/test.ccm-11.keepalive-management.ts` |
|
||||
|
||||
### 11. Client Command Execution (CCMD)
|
||||
|
||||
Tests for validating how the client sends SMTP commands and processes responses.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CCMD-01 | EHLO/HELO Command Sending | High | `suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts` |
|
||||
| CCMD-02 | MAIL FROM Command with Parameters | High | `suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts` |
|
||||
| CCMD-03 | RCPT TO Command with Multiple Recipients | High | `suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts` |
|
||||
| CCMD-04 | DATA Command and Content Transmission | High | `suite/smtpclient_commands/test.ccmd-04.data-transmission.ts` |
|
||||
| CCMD-05 | AUTH Command (LOGIN, PLAIN, CRAM-MD5) | High | `suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts` |
|
||||
| CCMD-06 | Command Pipelining | Medium | `suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts` |
|
||||
| CCMD-07 | Response Code Parsing | High | `suite/smtpclient_commands/test.ccmd-07.response-parsing.ts` |
|
||||
| CCMD-08 | Extended Response Handling | Medium | `suite/smtpclient_commands/test.ccmd-08.extended-responses.ts` |
|
||||
| CCMD-09 | QUIT Command and Graceful Disconnect | High | `suite/smtpclient_commands/test.ccmd-09.quit-disconnect.ts` |
|
||||
| CCMD-10 | RSET Command Usage | Medium | `suite/smtpclient_commands/test.ccmd-10.rset-usage.ts` |
|
||||
| CCMD-11 | NOOP Keep-Alive | Low | `suite/smtpclient_commands/test.ccmd-11.noop-keepalive.ts` |
|
||||
|
||||
### 12. Client Email Composition (CEP)
|
||||
|
||||
Tests for validating email composition, formatting, and encoding.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CEP-01 | Basic Email Headers | High | `suite/smtpclient_email-composition/test.cep-01.basic-headers.ts` |
|
||||
| CEP-02 | MIME Multipart Messages | High | `suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts` |
|
||||
| CEP-03 | Attachment Encoding | High | `suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts` |
|
||||
| CEP-04 | UTF-8 and International Characters | High | `suite/smtpclient_email-composition/test.cep-04.utf8-international.ts` |
|
||||
| CEP-05 | Base64 and Quoted-Printable Encoding | Medium | `suite/smtpclient_email-composition/test.cep-05.content-encoding.ts` |
|
||||
| CEP-06 | HTML Email with Inline Images | Medium | `suite/smtpclient_email-composition/test.cep-06.html-inline-images.ts` |
|
||||
| CEP-07 | Custom Headers | Low | `suite/smtpclient_email-composition/test.cep-07.custom-headers.ts` |
|
||||
| CEP-08 | Message-ID Generation | Medium | `suite/smtpclient_email-composition/test.cep-08.message-id.ts` |
|
||||
| CEP-09 | Date Header Formatting | Medium | `suite/smtpclient_email-composition/test.cep-09.date-formatting.ts` |
|
||||
| CEP-10 | Line Length Limits (RFC 5322) | High | `suite/smtpclient_email-composition/test.cep-10.line-length-limits.ts` |
|
||||
|
||||
### 13. Client Security (CSEC)
|
||||
|
||||
Tests for client-side security features and protections.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CSEC-01 | TLS Certificate Verification | High | `suite/smtpclient_security/test.csec-01.tls-verification.ts` |
|
||||
| CSEC-02 | Authentication Mechanisms | High | `suite/smtpclient_security/test.csec-02.auth-mechanisms.ts` |
|
||||
| CSEC-03 | OAuth2 Support | Medium | `suite/smtpclient_security/test.csec-03.oauth2-support.ts` |
|
||||
| CSEC-04 | Password Security (No Plaintext) | High | `suite/smtpclient_security/test.csec-04.password-security.ts` |
|
||||
| CSEC-05 | DKIM Signing | High | `suite/smtpclient_security/test.csec-05.dkim-signing.ts` |
|
||||
| CSEC-06 | SPF Record Compliance | Medium | `suite/smtpclient_security/test.csec-06.spf-compliance.ts` |
|
||||
| CSEC-07 | Secure Credential Storage | High | `suite/smtpclient_security/test.csec-07.credential-storage.ts` |
|
||||
| CSEC-08 | TLS Version Enforcement | High | `suite/smtpclient_security/test.csec-08.tls-version-enforcement.ts` |
|
||||
| CSEC-09 | Certificate Pinning | Low | `suite/smtpclient_security/test.csec-09.certificate-pinning.ts` |
|
||||
| CSEC-10 | Injection Attack Prevention | High | `suite/smtpclient_security/test.csec-10.injection-prevention.ts` |
|
||||
|
||||
### 14. Client Error Handling (CERR)
|
||||
|
||||
Tests for how the client handles various error conditions.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CERR-01 | 4xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts` |
|
||||
| CERR-02 | 5xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts` |
|
||||
| CERR-03 | Network Failure Recovery | High | `suite/smtpclient_error-handling/test.cerr-03.network-failures.ts` |
|
||||
| CERR-04 | Timeout Recovery | High | `suite/smtpclient_error-handling/test.cerr-04.timeout-recovery.ts` |
|
||||
| CERR-05 | Retry Logic with Backoff | High | `suite/smtpclient_error-handling/test.cerr-05.retry-backoff.ts` |
|
||||
| CERR-06 | Greylisting Handling | Medium | `suite/smtpclient_error-handling/test.cerr-06.greylisting.ts` |
|
||||
| CERR-07 | Rate Limit Response Handling | High | `suite/smtpclient_error-handling/test.cerr-07.rate-limits.ts` |
|
||||
| CERR-08 | Malformed Server Response | Medium | `suite/smtpclient_error-handling/test.cerr-08.malformed-responses.ts` |
|
||||
| CERR-09 | Connection Drop During Transfer | High | `suite/smtpclient_error-handling/test.cerr-09.connection-drops.ts` |
|
||||
| CERR-10 | Authentication Failure Handling | High | `suite/smtpclient_error-handling/test.cerr-10.auth-failures.ts` |
|
||||
|
||||
### 15. Client Performance (CPERF)
|
||||
|
||||
Tests for client performance characteristics and optimization.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|----------|-------------------------------------------|----------|----------------|
|
||||
| CPERF-01 | Bulk Email Sending | High | `suite/smtpclient_performance/test.cperf-01.bulk-sending.ts` |
|
||||
| CPERF-02 | Connection Pool Efficiency | High | `suite/smtpclient_performance/test.cperf-02.pool-efficiency.ts` |
|
||||
| CPERF-03 | Memory Usage Under Load | High | `suite/smtpclient_performance/test.cperf-03.memory-usage.ts` |
|
||||
| CPERF-04 | CPU Usage Optimization | Medium | `suite/smtpclient_performance/test.cperf-04.cpu-optimization.ts` |
|
||||
| CPERF-05 | Parallel Sending Performance | High | `suite/smtpclient_performance/test.cperf-05.parallel-sending.ts` |
|
||||
| CPERF-06 | Large Attachment Handling | Medium | `suite/smtpclient_performance/test.cperf-06.large-attachments.ts` |
|
||||
| CPERF-07 | Queue Management | High | `suite/smtpclient_performance/test.cperf-07.queue-management.ts` |
|
||||
| CPERF-08 | DNS Caching Efficiency | Medium | `suite/smtpclient_performance/test.cperf-08.dns-caching.ts` |
|
||||
|
||||
### 16. Client Reliability (CREL)
|
||||
|
||||
Tests for client reliability and resilience.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CREL-01 | Long Running Stability | High | `suite/smtpclient_reliability/test.crel-01.long-running.ts` |
|
||||
| CREL-02 | Failover to Backup MX | High | `suite/smtpclient_reliability/test.crel-02.mx-failover.ts` |
|
||||
| CREL-03 | Queue Persistence | High | `suite/smtpclient_reliability/test.crel-03.queue-persistence.ts` |
|
||||
| CREL-04 | Crash Recovery | High | `suite/smtpclient_reliability/test.crel-04.crash-recovery.ts` |
|
||||
| CREL-05 | Memory Leak Prevention | High | `suite/smtpclient_reliability/test.crel-05.memory-leaks.ts` |
|
||||
| CREL-06 | Concurrent Operation Safety | High | `suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts` |
|
||||
| CREL-07 | Resource Cleanup | Medium | `suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts` |
|
||||
|
||||
### 17. Client Edge Cases (CEDGE)
|
||||
|
||||
Tests for unusual scenarios and edge cases.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|----------|-------------------------------------------|----------|----------------|
|
||||
| CEDGE-01 | Extremely Slow Server Response | Medium | `suite/smtpclient_edge-cases/test.cedge-01.slow-server.ts` |
|
||||
| CEDGE-02 | Server Sending Invalid UTF-8 | Low | `suite/smtpclient_edge-cases/test.cedge-02.invalid-utf8.ts` |
|
||||
| CEDGE-03 | Extremely Large Recipients List | Medium | `suite/smtpclient_edge-cases/test.cedge-03.large-recipient-list.ts` |
|
||||
| CEDGE-04 | Zero-Byte Attachments | Low | `suite/smtpclient_edge-cases/test.cedge-04.zero-byte-attachments.ts` |
|
||||
| CEDGE-05 | Server Disconnect Mid-Command | High | `suite/smtpclient_edge-cases/test.cedge-05.mid-command-disconnect.ts` |
|
||||
| CEDGE-06 | Unusual Server Banners | Low | `suite/smtpclient_edge-cases/test.cedge-06.unusual-banners.ts` |
|
||||
| CEDGE-07 | Non-Standard Port Connections | Medium | `suite/smtpclient_edge-cases/test.cedge-07.non-standard-ports.ts` |
|
||||
|
||||
### 18. Client RFC Compliance (CRFC)
|
||||
|
||||
Tests for RFC compliance from the client perspective.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CRFC-01 | RFC 5321 Client Requirements | High | `suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts` |
|
||||
| CRFC-02 | RFC 5322 Message Format | High | `suite/smtpclient_rfc-compliance/test.crfc-02.rfc5322-format.ts` |
|
||||
| CRFC-03 | RFC 2045-2049 MIME Compliance | High | `suite/smtpclient_rfc-compliance/test.crfc-03.mime-compliance.ts` |
|
||||
| CRFC-04 | RFC 4954 AUTH Extension | High | `suite/smtpclient_rfc-compliance/test.crfc-04.auth-extension.ts` |
|
||||
| CRFC-05 | RFC 3207 STARTTLS | High | `suite/smtpclient_rfc-compliance/test.crfc-05.starttls.ts` |
|
||||
| CRFC-06 | RFC 1870 SIZE Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-06.size-extension.ts` |
|
||||
| CRFC-07 | RFC 6152 8BITMIME Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-07.8bitmime.ts` |
|
||||
| CRFC-08 | RFC 2920 Command Pipelining | Medium | `suite/smtpclient_rfc-compliance/test.crfc-08.pipelining.ts` |
|
||||
|
||||
## Running SMTP Client Tests
|
||||
|
||||
### Run All Client Tests
|
||||
```bash
|
||||
cd dcrouter
|
||||
pnpm test test/suite/smtpclient_*
|
||||
```
|
||||
|
||||
### Run Specific Client Test Category
|
||||
```bash
|
||||
# Run all client connection tests
|
||||
pnpm test test/suite/smtpclient_connection
|
||||
|
||||
# Run all client security tests
|
||||
pnpm test test/suite/smtpclient_security
|
||||
```
|
||||
|
||||
### Run Single Client Test File
|
||||
```bash
|
||||
# Run basic TCP connection test
|
||||
tsx test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts
|
||||
|
||||
# Run AUTH mechanisms test
|
||||
tsx test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts
|
||||
```
|
||||
|
||||
## Client Performance Benchmarks
|
||||
|
||||
Expected performance metrics for production-ready SMTP client:
|
||||
- **Sending Rate**: >100 emails per second (with connection pooling)
|
||||
- **Connection Pool Size**: 10-50 concurrent connections efficiently managed
|
||||
- **Memory Usage**: <500MB for 1000 concurrent email operations
|
||||
- **DNS Cache Hit Rate**: >90% for repeated domains
|
||||
- **Retry Success Rate**: >95% for temporary failures
|
||||
- **Large Attachment Support**: Files up to 25MB without performance degradation
|
||||
- **Queue Processing**: >1000 emails/minute with persistent queue
|
||||
|
||||
## Client Security Requirements
|
||||
|
||||
All client security tests must pass for production deployment:
|
||||
- **TLS Support**: TLS 1.2+ required, TLS 1.3 preferred
|
||||
- **Authentication**: Support for LOGIN, PLAIN, CRAM-MD5, OAuth2
|
||||
- **Certificate Validation**: Proper certificate chain validation
|
||||
- **DKIM Signing**: Automatic DKIM signature generation
|
||||
- **Credential Security**: No plaintext password storage
|
||||
- **Injection Prevention**: Protection against header/command injection
|
||||
|
||||
## Client Production Readiness Criteria
|
||||
|
||||
### Production Gate 1: Core Functionality (>95% tests passing)
|
||||
- Basic connection establishment
|
||||
- Command execution and response parsing
|
||||
- Email composition and sending
|
||||
- Error handling and recovery
|
||||
|
||||
### Production Gate 2: Advanced Features (>90% tests passing)
|
||||
- Connection pooling and reuse
|
||||
- Authentication mechanisms
|
||||
- TLS/STARTTLS support
|
||||
- Retry logic and resilience
|
||||
|
||||
### Production Gate 3: Enterprise Ready (>85% tests passing)
|
||||
- High-volume sending capabilities
|
||||
- Advanced security features
|
||||
- Full RFC compliance
|
||||
- Performance under load
|
||||
|
||||
## Key Differences: Server vs Client Tests
|
||||
|
||||
| Aspect | Server Tests | Client Tests |
|
||||
|--------|--------------|--------------|
|
||||
| **Focus** | Accepting connections, processing commands | Making connections, sending commands |
|
||||
| **Security** | Validating incoming data, enforcing policies | Protecting credentials, validating servers |
|
||||
| **Performance** | Handling many clients concurrently | Efficient bulk sending, connection reuse |
|
||||
| **Reliability** | Staying up under attack/load | Retrying failures, handling timeouts |
|
||||
| **RFC Compliance** | Server MUST requirements | Client MUST requirements |
|
||||
|
||||
## Test Implementation Priority
|
||||
|
||||
1. **Critical** (implement first):
|
||||
- Basic connection and command sending
|
||||
- Authentication mechanisms
|
||||
- Error handling and retry logic
|
||||
- TLS/Security features
|
||||
|
||||
2. **High Priority** (implement second):
|
||||
- Connection pooling
|
||||
- Email composition and MIME
|
||||
- Performance optimization
|
||||
- RFC compliance
|
||||
|
||||
3. **Medium Priority** (implement third):
|
||||
- Advanced features (OAuth2, etc.)
|
||||
- Edge case handling
|
||||
- Extended performance tests
|
||||
- Additional RFC extensions
|
||||
|
||||
4. **Low Priority** (implement last):
|
||||
- Proxy support
|
||||
- Certificate pinning
|
||||
- Unusual scenarios
|
||||
- Optional RFC features
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js';
|
||||
|
||||
/**
|
||||
* Basic test to check if our integrated classes work correctly
|
||||
*/
|
||||
tap.test('verify that SenderReputationMonitor and IPWarmupManager are functioning', async () => {
|
||||
// Create instances of both classes
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com']
|
||||
});
|
||||
|
||||
// Test SenderReputationMonitor
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
|
||||
const reputationData = reputationMonitor.getReputationData('example.com');
|
||||
expect(reputationData).toBeTruthy();
|
||||
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
expect(summary.length).toBeGreaterThan(0);
|
||||
|
||||
// Add and remove domains
|
||||
reputationMonitor.addDomain('test.com');
|
||||
reputationMonitor.removeDomain('test.com');
|
||||
|
||||
// Test IPWarmupManager
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
ipWarmupManager.recordSend(bestIP);
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(bestIP);
|
||||
expect(typeof canSendMore).toEqual('boolean');
|
||||
}
|
||||
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(stageCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,197 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SzPlatformService } from '../ts/platformservice.js';
|
||||
import { BounceManager, BounceType, BounceCategory } from '../ts/email/classes.bouncemanager.js';
|
||||
|
||||
/**
|
||||
* Test the BounceManager class
|
||||
*/
|
||||
tap.test('BounceManager - should be instantiable', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
expect(bounceManager).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should process basic bounce categories', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
// Test hard bounce detection
|
||||
const hardBounce = await bounceManager.processBounce({
|
||||
recipient: 'invalid@example.com',
|
||||
sender: 'sender@example.com',
|
||||
smtpResponse: 'user unknown',
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(hardBounce.bounceCategory).toEqual(BounceCategory.HARD);
|
||||
|
||||
// Test soft bounce detection
|
||||
const softBounce = await bounceManager.processBounce({
|
||||
recipient: 'valid@example.com',
|
||||
sender: 'sender@example.com',
|
||||
smtpResponse: 'server unavailable',
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(softBounce.bounceCategory).toEqual(BounceCategory.SOFT);
|
||||
|
||||
// Test auto-response detection
|
||||
const autoResponse = await bounceManager.processBounce({
|
||||
recipient: 'away@example.com',
|
||||
sender: 'sender@example.com',
|
||||
smtpResponse: 'auto-reply: out of office',
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(autoResponse.bounceCategory).toEqual(BounceCategory.AUTO_RESPONSE);
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should add and check suppression list entries', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
// Add to suppression list permanently
|
||||
bounceManager.addToSuppressionList('permanent@example.com', 'Test hard bounce', undefined);
|
||||
|
||||
// Add to suppression list temporarily (5 seconds)
|
||||
const expireTime = Date.now() + 5000;
|
||||
bounceManager.addToSuppressionList('temporary@example.com', 'Test soft bounce', expireTime);
|
||||
|
||||
// Check suppression status
|
||||
expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true);
|
||||
expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(true);
|
||||
expect(bounceManager.isEmailSuppressed('notsuppressed@example.com')).toEqual(false);
|
||||
|
||||
// Get suppression info
|
||||
const info = bounceManager.getSuppressionInfo('permanent@example.com');
|
||||
expect(info).toBeTruthy();
|
||||
expect(info.reason).toEqual('Test hard bounce');
|
||||
expect(info.expiresAt).toBeUndefined();
|
||||
|
||||
// Verify temporary suppression info
|
||||
const tempInfo = bounceManager.getSuppressionInfo('temporary@example.com');
|
||||
expect(tempInfo).toBeTruthy();
|
||||
expect(tempInfo.reason).toEqual('Test soft bounce');
|
||||
expect(tempInfo.expiresAt).toEqual(expireTime);
|
||||
|
||||
// Wait for expiration (6 seconds)
|
||||
await new Promise(resolve => setTimeout(resolve, 6000));
|
||||
|
||||
// Verify permanent suppression is still active
|
||||
expect(bounceManager.isEmailSuppressed('permanent@example.com')).toEqual(true);
|
||||
|
||||
// Verify temporary suppression has expired
|
||||
expect(bounceManager.isEmailSuppressed('temporary@example.com')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should process SMTP failures correctly', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
const result = await bounceManager.processSmtpFailure(
|
||||
'recipient@example.com',
|
||||
'550 5.1.1 User unknown',
|
||||
{
|
||||
sender: 'sender@example.com',
|
||||
statusCode: '550'
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.bounceType).toEqual(BounceType.INVALID_RECIPIENT);
|
||||
expect(result.bounceCategory).toEqual(BounceCategory.HARD);
|
||||
|
||||
// Check that the email was added to the suppression list
|
||||
expect(bounceManager.isEmailSuppressed('recipient@example.com')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should process bounce emails correctly', async () => {
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
// Create a mock bounce email as Smartmail
|
||||
const bounceEmail = new plugins.smartmail.Smartmail({
|
||||
from: 'mailer-daemon@example.com',
|
||||
subject: 'Mail delivery failed: returning message to sender',
|
||||
body: `
|
||||
This message was created automatically by mail delivery software.
|
||||
|
||||
A message that you sent could not be delivered to one or more of its recipients.
|
||||
The following address(es) failed:
|
||||
|
||||
recipient@example.com
|
||||
mailbox is full
|
||||
|
||||
------ This is a copy of the message, including all the headers. ------
|
||||
|
||||
Original-Recipient: rfc822;recipient@example.com
|
||||
Final-Recipient: rfc822;recipient@example.com
|
||||
Status: 5.2.2
|
||||
diagnostic-code: smtp; 552 5.2.2 Mailbox full
|
||||
`,
|
||||
creationObjectRef: {}
|
||||
});
|
||||
|
||||
const result = await bounceManager.processBounceEmail(bounceEmail);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.bounceType).toEqual(BounceType.MAILBOX_FULL);
|
||||
expect(result.bounceCategory).toEqual(BounceCategory.HARD);
|
||||
expect(result.recipient).toEqual('recipient@example.com');
|
||||
});
|
||||
|
||||
tap.test('BounceManager - should handle retries for soft bounces', async () => {
|
||||
const bounceManager = new BounceManager({
|
||||
retryStrategy: {
|
||||
maxRetries: 2,
|
||||
initialDelay: 100, // 100ms for test
|
||||
maxDelay: 1000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
});
|
||||
|
||||
// First attempt
|
||||
const result1 = await bounceManager.processBounce({
|
||||
recipient: 'retry@example.com',
|
||||
sender: 'sender@example.com',
|
||||
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||
bounceCategory: BounceCategory.SOFT,
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Email should be suppressed temporarily
|
||||
expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true);
|
||||
expect(result1.retryCount).toEqual(1);
|
||||
expect(result1.nextRetryTime).toBeGreaterThan(Date.now());
|
||||
|
||||
// Second attempt
|
||||
const result2 = await bounceManager.processBounce({
|
||||
recipient: 'retry@example.com',
|
||||
sender: 'sender@example.com',
|
||||
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||
bounceCategory: BounceCategory.SOFT,
|
||||
domain: 'example.com',
|
||||
retryCount: 1
|
||||
});
|
||||
|
||||
expect(result2.retryCount).toEqual(2);
|
||||
|
||||
// Third attempt (should convert to hard bounce)
|
||||
const result3 = await bounceManager.processBounce({
|
||||
recipient: 'retry@example.com',
|
||||
sender: 'sender@example.com',
|
||||
bounceType: BounceType.SERVER_UNAVAILABLE,
|
||||
bounceCategory: BounceCategory.SOFT,
|
||||
domain: 'example.com',
|
||||
retryCount: 2
|
||||
});
|
||||
|
||||
// Should now be a hard bounce after max retries
|
||||
expect(result3.bounceCategory).toEqual(BounceCategory.HARD);
|
||||
|
||||
// Email should be suppressed permanently
|
||||
expect(bounceManager.isEmailSuppressed('retry@example.com')).toEqual(true);
|
||||
const info = bounceManager.getSuppressionInfo('retry@example.com');
|
||||
expect(info.expiresAt).toBeUndefined(); // Permanent
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
175
test/test.config.md
Normal file
175
test/test.config.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# DCRouter Test Configuration
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
cd dcrouter
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Run Specific Category
|
||||
```bash
|
||||
# Run all connection tests
|
||||
tsx test/run-category.ts connection
|
||||
|
||||
# Run all security tests
|
||||
tsx test/run-category.ts security
|
||||
|
||||
# Run all performance tests
|
||||
tsx test/run-category.ts performance
|
||||
```
|
||||
|
||||
### Run Individual Test File
|
||||
```bash
|
||||
# Run TLS connection test
|
||||
tsx test/suite/connection/test.tls-connection.ts
|
||||
|
||||
# Run authentication test
|
||||
tsx test/suite/security/test.authentication.ts
|
||||
```
|
||||
|
||||
### Run Tests with Verbose Output
|
||||
```bash
|
||||
# All tests with verbose logging
|
||||
pnpm test -- --verbose
|
||||
|
||||
# Individual test with verbose
|
||||
tsx test/suite/connection/test.tls-connection.ts --verbose
|
||||
```
|
||||
|
||||
## Test Server Configuration
|
||||
|
||||
Each test file starts its own SMTP server with specific configuration. Common configurations:
|
||||
|
||||
### Basic Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost'
|
||||
});
|
||||
```
|
||||
|
||||
### TLS-Enabled Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
tlsEnabled: true
|
||||
});
|
||||
```
|
||||
|
||||
### Authenticated Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
authRequired: true
|
||||
});
|
||||
```
|
||||
|
||||
### High-Performance Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
maxConnections: 1000,
|
||||
size: 50 * 1024 * 1024 // 50MB
|
||||
});
|
||||
```
|
||||
|
||||
## Port Allocation
|
||||
|
||||
Tests use different ports to avoid conflicts:
|
||||
- Connection tests: 2525-2530
|
||||
- Command tests: 2531-2540
|
||||
- Email processing: 2541-2550
|
||||
- Security tests: 2551-2560
|
||||
- Performance tests: 2561-2570
|
||||
- Edge cases: 2571-2580
|
||||
- RFC compliance: 2581-2590
|
||||
|
||||
## Test Utilities
|
||||
|
||||
### Server Lifecycle
|
||||
All tests follow this pattern:
|
||||
```typescript
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
let testServer;
|
||||
|
||||
tap.test('setup', async () => {
|
||||
testServer = await startTestServer({ port: 2525 });
|
||||
});
|
||||
|
||||
// Your tests here...
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
```
|
||||
|
||||
### SMTP Client Testing
|
||||
```typescript
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
const client = createTestSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525
|
||||
});
|
||||
```
|
||||
|
||||
### Low-Level SMTP Testing
|
||||
```typescript
|
||||
import { connectToSmtp, sendSmtpCommand } from '../../helpers/test.utils.js';
|
||||
|
||||
const socket = await connectToSmtp('localhost', 2525);
|
||||
const response = await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
||||
```
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
Expected minimums for production:
|
||||
- Throughput: >10 emails/second
|
||||
- Concurrent connections: >100
|
||||
- Memory increase: <2% under load
|
||||
- Connection time: <5000ms
|
||||
- Error rate: <5%
|
||||
|
||||
## Debugging Failed Tests
|
||||
|
||||
### Enable Verbose Logging
|
||||
```bash
|
||||
DEBUG=* tsx test/suite/connection/test.tls-connection.ts
|
||||
```
|
||||
|
||||
### Check Server Logs
|
||||
Tests output server logs to console. Look for:
|
||||
- 🚀 Server start messages
|
||||
- 📧 Email processing logs
|
||||
- ❌ Error messages
|
||||
- ✅ Success confirmations
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port Already in Use**
|
||||
- Tests use unique ports
|
||||
- Check for orphaned processes: `lsof -i :2525`
|
||||
- Kill process: `kill -9 <PID>`
|
||||
|
||||
2. **TLS Certificate Errors**
|
||||
- Tests use self-signed certificates
|
||||
- Production should use real certificates
|
||||
|
||||
3. **Timeout Errors**
|
||||
- Increase timeout in test configuration
|
||||
- Check network connectivity
|
||||
- Verify server started successfully
|
||||
|
||||
4. **Authentication Failures**
|
||||
- Test servers may not validate credentials
|
||||
- Check authRequired configuration
|
||||
- Verify AUTH mechanisms supported
|
||||
@@ -1,6 +1,6 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
||||
import { Email } from '../ts/mta/classes.email.js';
|
||||
import { Email } from '@push.rocks/smartmta';
|
||||
|
||||
// Test instantiation
|
||||
tap.test('ContentScanner - should be instantiable', async () => {
|
||||
|
||||
159
test/test.dcrouter.email.ts
Normal file
159
test/test.dcrouter.email.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
|
||||
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
// Define custom port mapping
|
||||
const customPortMapping: Record<number, number> = {
|
||||
25: 11025, // Custom SMTP port mapping
|
||||
587: 11587, // Custom submission port mapping
|
||||
465: 11465, // Custom SMTPS port mapping
|
||||
2525: 12525 // Additional custom port
|
||||
};
|
||||
|
||||
// Create a custom email configuration using smartmta interfaces
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [25, 587, 465, 2525],
|
||||
hostname: 'mail.example.com',
|
||||
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns',
|
||||
},
|
||||
{
|
||||
domain: 'example.org',
|
||||
dnsMode: 'external-dns',
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'forward-example-com',
|
||||
match: {
|
||||
recipients: '*@example.com',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: 'mail1.example.com',
|
||||
port: 25,
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'deliver-example-org',
|
||||
match: {
|
||||
recipients: '*@example.org',
|
||||
},
|
||||
action: {
|
||||
type: 'deliver',
|
||||
process: {
|
||||
dkim: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Create DcRouter options with custom email port configuration
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
emailPortConfig: {
|
||||
portMapping: customPortMapping,
|
||||
portSettings: {
|
||||
2525: {
|
||||
terminateTls: false,
|
||||
routeName: 'custom-smtp-route'
|
||||
}
|
||||
},
|
||||
},
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
// Create DcRouter instance
|
||||
const router = new DcRouter(options);
|
||||
|
||||
// Verify the options are correctly set
|
||||
expect(router.options.emailPortConfig).toBeTruthy();
|
||||
expect(router.options.emailPortConfig!.portMapping).toEqual(customPortMapping);
|
||||
|
||||
// Test the generateEmailRoutes method
|
||||
if (typeof (router as any)['generateEmailRoutes'] === 'function') {
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
|
||||
// Verify that all ports are configured
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
// Check the custom port configuration
|
||||
const customPortRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 2525 || (Array.isArray(ports) && (ports as number[]).includes(2525));
|
||||
});
|
||||
expect(customPortRoute).toBeTruthy();
|
||||
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
||||
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
|
||||
|
||||
// Check standard port mappings
|
||||
const smtpRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 25 || (Array.isArray(ports) && (ports as number[]).includes(25));
|
||||
});
|
||||
expect(smtpRoute?.action.targets[0].port).toEqual(11025);
|
||||
|
||||
const submissionRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 587 || (Array.isArray(ports) && (ports as number[]).includes(587));
|
||||
});
|
||||
expect(submissionRoute?.action.targets[0].port).toEqual(11587);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
// Create a basic email configuration
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: []
|
||||
};
|
||||
|
||||
// Create DcRouter options
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
cacheConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
};
|
||||
|
||||
// Create DcRouter instance
|
||||
const router = new DcRouter(options);
|
||||
|
||||
// Start the router to initialize email services
|
||||
await router.start();
|
||||
|
||||
// Verify unified email server was initialized
|
||||
expect(router.emailServer).toBeTruthy();
|
||||
|
||||
// Stop the router
|
||||
await router.stop();
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,55 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
|
||||
// Import the components we want to test
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js';
|
||||
|
||||
// Ensure test directories exist
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Test SenderReputationMonitor functionality
|
||||
tap.test('SenderReputationMonitor should track sending events', async () => {
|
||||
// Initialize monitor with test domain
|
||||
const monitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['test-domain.com']
|
||||
});
|
||||
|
||||
// Record some events
|
||||
monitor.recordSendEvent('test-domain.com', { type: 'sent', count: 100 });
|
||||
monitor.recordSendEvent('test-domain.com', { type: 'delivered', count: 95 });
|
||||
|
||||
// Get domain metrics
|
||||
const metrics = monitor.getReputationData('test-domain.com');
|
||||
|
||||
// Verify metrics were recorded
|
||||
if (metrics) {
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
}
|
||||
});
|
||||
|
||||
// Test IPWarmupManager functionality
|
||||
tap.test('IPWarmupManager should handle IP allocation policies', async () => {
|
||||
// Initialize warmup manager
|
||||
const manager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['test-domain.com']
|
||||
});
|
||||
|
||||
// Set allocation policy
|
||||
manager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Verify allocation methods work
|
||||
const canSend = manager.canSendMoreToday('192.168.1.1');
|
||||
expect(typeof canSend).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
140
test/test.dns-server-config.ts
Normal file
140
test/test.dns-server-config.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Test DNS server configuration and record registration
|
||||
*/
|
||||
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Test DNS configuration
|
||||
const testDnsConfig = {
|
||||
udpPort: 5353, // Use non-privileged port for testing
|
||||
httpsPort: 8443,
|
||||
httpsKey: './test/fixtures/test-key.pem',
|
||||
httpsCert: './test/fixtures/test-cert.pem',
|
||||
dnssecZone: 'test.example.com',
|
||||
records: [
|
||||
{ name: 'test.example.com', type: 'A', value: '192.168.1.1' },
|
||||
{ name: 'mail.test.example.com', type: 'A', value: '192.168.1.2' },
|
||||
{ name: 'test.example.com', type: 'MX', value: '10 mail.test.example.com' },
|
||||
{ name: 'test.example.com', type: 'TXT', value: 'v=spf1 a:mail.test.example.com ~all' },
|
||||
{ name: 'test.example.com', type: 'NS', value: 'ns1.test.example.com' },
|
||||
{ name: 'ns1.test.example.com', type: 'A', value: '192.168.1.1' }
|
||||
]
|
||||
};
|
||||
|
||||
tap.test('DNS server configuration - should extract records correctly', async () => {
|
||||
const { records, ...dnsServerOptions } = testDnsConfig;
|
||||
|
||||
expect(dnsServerOptions.udpPort).toEqual(5353);
|
||||
expect(dnsServerOptions.httpsPort).toEqual(8443);
|
||||
expect(dnsServerOptions.dnssecZone).toEqual('test.example.com');
|
||||
expect(records).toBeArray();
|
||||
expect(records.length).toEqual(6);
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should handle record parsing', async () => {
|
||||
const parseDnsRecordData = (type: string, value: string): any => {
|
||||
switch (type) {
|
||||
case 'A':
|
||||
return value;
|
||||
case 'MX':
|
||||
const [priority, exchange] = value.split(' ');
|
||||
return { priority: parseInt(priority), exchange };
|
||||
case 'TXT':
|
||||
return value;
|
||||
case 'NS':
|
||||
return value;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// Test A record parsing
|
||||
const aRecord = parseDnsRecordData('A', '192.168.1.1');
|
||||
expect(aRecord).toEqual('192.168.1.1');
|
||||
|
||||
// Test MX record parsing
|
||||
const mxRecord = parseDnsRecordData('MX', '10 mail.test.example.com');
|
||||
expect(mxRecord).toHaveProperty('priority', 10);
|
||||
expect(mxRecord).toHaveProperty('exchange', 'mail.test.example.com');
|
||||
|
||||
// Test TXT record parsing
|
||||
const txtRecord = parseDnsRecordData('TXT', 'v=spf1 a:mail.test.example.com ~all');
|
||||
expect(txtRecord).toEqual('v=spf1 a:mail.test.example.com ~all');
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should group records by domain', async () => {
|
||||
const records = testDnsConfig.records;
|
||||
const recordsByDomain = new Map<string, typeof records>();
|
||||
|
||||
for (const record of records) {
|
||||
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||
if (!recordsByDomain.has(pattern)) {
|
||||
recordsByDomain.set(pattern, []);
|
||||
}
|
||||
recordsByDomain.get(pattern)!.push(record);
|
||||
}
|
||||
|
||||
// Check grouping
|
||||
expect(recordsByDomain.size).toBeGreaterThan(0);
|
||||
|
||||
// Verify each group has records
|
||||
for (const [pattern, domainRecords] of recordsByDomain) {
|
||||
expect(domainRecords.length).toBeGreaterThan(0);
|
||||
console.log(`Pattern: ${pattern}, Records: ${domainRecords.length}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should extract unique record types', async () => {
|
||||
const records = testDnsConfig.records;
|
||||
const recordTypes = [...new Set(records.map(r => r.type))];
|
||||
|
||||
expect(recordTypes).toContain('A');
|
||||
expect(recordTypes).toContain('MX');
|
||||
expect(recordTypes).toContain('TXT');
|
||||
expect(recordTypes).toContain('NS');
|
||||
|
||||
console.log('Unique record types:', recordTypes.join(', '));
|
||||
});
|
||||
|
||||
tap.test('DNS server - mock handler registration', async () => {
|
||||
// Mock DNS server for testing
|
||||
const mockDnsServer = {
|
||||
handlers: new Map<string, any>(),
|
||||
registerHandler: function(pattern: string, types: string[], handler: Function) {
|
||||
this.handlers.set(pattern, { types, handler });
|
||||
console.log(`Registered handler for pattern: ${pattern}, types: ${types.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate record registration
|
||||
const records = testDnsConfig.records;
|
||||
const recordsByDomain = new Map<string, typeof records>();
|
||||
|
||||
for (const record of records) {
|
||||
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||
if (!recordsByDomain.has(pattern)) {
|
||||
recordsByDomain.set(pattern, []);
|
||||
}
|
||||
recordsByDomain.get(pattern)!.push(record);
|
||||
}
|
||||
|
||||
// Register handlers
|
||||
for (const [domainPattern, domainRecords] of recordsByDomain) {
|
||||
const recordTypes = [...new Set(domainRecords.map(r => r.type))];
|
||||
mockDnsServer.registerHandler(domainPattern, recordTypes, (question: any) => {
|
||||
const matchingRecord = domainRecords.find(
|
||||
r => r.name === question.name && r.type === question.type
|
||||
);
|
||||
return matchingRecord || null;
|
||||
});
|
||||
}
|
||||
|
||||
expect(mockDnsServer.handlers.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.start({
|
||||
throwOnError: true
|
||||
});
|
||||
148
test/test.dns-socket-handler.ts
Normal file
148
test/test.dns-socket-handler.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
},
|
||||
cacheConfig: { enabled: false }
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Check that DNS server is not created
|
||||
expect((dcRouter as any).dnsServer).toBeUndefined();
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should generate DNS routes when dnsNsDomains is set', async () => {
|
||||
// This test checks the route generation logic WITHOUT starting the full DcRouter
|
||||
// Starting DcRouter would require DNS port 53 and cause conflicts
|
||||
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
|
||||
dnsScopes: ['test.local'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Check routes are generated correctly (without starting)
|
||||
const generatedRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
expect(generatedRoutes.length).toEqual(2); // /dns-query and /resolve
|
||||
|
||||
// Check that routes have socket-handler action
|
||||
generatedRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
});
|
||||
|
||||
// Verify routes target the primary nameserver
|
||||
const dnsQueryRoute = generatedRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(dnsQueryRoute.match.domains).toContain('ns1.test.local');
|
||||
});
|
||||
|
||||
tap.test('should create DNS routes with correct configuration', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Access the private method to generate routes
|
||||
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
|
||||
expect(dnsRoutes.length).toEqual(2);
|
||||
|
||||
// Check first route (dns-query) - uses primary nameserver (first in array)
|
||||
const dnsQueryRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(dnsQueryRoute.match.ports).toContain(443);
|
||||
expect(dnsQueryRoute.match.domains).toContain('ns1.example.com');
|
||||
expect(dnsQueryRoute.match.path).toEqual('/dns-query');
|
||||
|
||||
// Check second route (resolve)
|
||||
const resolveRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-resolve');
|
||||
expect(resolveRoute).toBeDefined();
|
||||
expect(resolveRoute.match.ports).toContain(443);
|
||||
expect(resolveRoute.match.domains).toContain('ns1.example.com');
|
||||
expect(resolveRoute.match.path).toEqual('/resolve');
|
||||
});
|
||||
|
||||
tap.test('DNS socket handler should be created correctly', async () => {
|
||||
// This test verifies the socket handler creation WITHOUT starting the full router
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
|
||||
dnsScopes: ['test.local'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Get the socket handler (this doesn't require DNS server to be started)
|
||||
const socketHandler = (dcRouter as any).createDnsSocketHandler();
|
||||
expect(socketHandler).toBeDefined();
|
||||
expect(typeof socketHandler).toEqual('function');
|
||||
|
||||
// Create a mock socket to test the handler behavior without DNS server
|
||||
const mockSocket = new plugins.net.Socket();
|
||||
let socketEnded = false;
|
||||
|
||||
mockSocket.end = () => {
|
||||
socketEnded = true;
|
||||
return mockSocket;
|
||||
};
|
||||
|
||||
// When DNS server is not initialized, the handler should end the socket
|
||||
try {
|
||||
await socketHandler(mockSocket);
|
||||
} catch (error) {
|
||||
// Expected - DNS server not initialized
|
||||
}
|
||||
|
||||
// Socket should be ended because DNS server wasn't started
|
||||
expect(socketEnded).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('DNS routes should only be generated when dnsNsDomains is configured', async () => {
|
||||
// Test without DNS configuration - should return empty routes
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
const routesWithoutDns = (dcRouter as any).generateDnsRoutes();
|
||||
expect(routesWithoutDns.length).toEqual(0);
|
||||
|
||||
// Test with DNS configuration - should return routes
|
||||
const dcRouterWithDns = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
const routesWithDns = (dcRouterWithDns as any).generateDnsRoutes();
|
||||
expect(routesWithDns.length).toEqual(2);
|
||||
|
||||
// Verify socket handler can be created
|
||||
const socketHandler = (dcRouterWithDns as any).createDnsSocketHandler();
|
||||
expect(socketHandler).toBeDefined();
|
||||
expect(typeof socketHandler).toEqual('function');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,209 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { SzPlatformService } from '../ts/platformservice.js';
|
||||
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mta/classes.spfverifier.js';
|
||||
import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mta/classes.dmarcverifier.js';
|
||||
import { Email } from '../ts/mta/classes.email.js';
|
||||
|
||||
/**
|
||||
* Test email authentication systems: SPF and DMARC
|
||||
*/
|
||||
|
||||
// Setup platform service for testing
|
||||
let platformService: SzPlatformService;
|
||||
|
||||
tap.test('Setup test environment', async () => {
|
||||
platformService = new SzPlatformService();
|
||||
// Use start() instead of init() which doesn't exist
|
||||
await platformService.start();
|
||||
expect(platformService.mtaService).toBeTruthy();
|
||||
});
|
||||
|
||||
// SPF Verifier Tests
|
||||
tap.test('SPF Verifier - should parse SPF record', async () => {
|
||||
const spfVerifier = new SpfVerifier(platformService.mtaService);
|
||||
|
||||
// Test valid SPF record parsing
|
||||
const record = 'v=spf1 a mx ip4:192.168.0.1/24 include:example.org ~all';
|
||||
const parsedRecord = spfVerifier.parseSpfRecord(record);
|
||||
|
||||
expect(parsedRecord).toBeTruthy();
|
||||
expect(parsedRecord.version).toEqual('spf1');
|
||||
expect(parsedRecord.mechanisms.length).toEqual(5);
|
||||
|
||||
// Check specific mechanisms
|
||||
expect(parsedRecord.mechanisms[0].type).toEqual(SpfMechanismType.A);
|
||||
expect(parsedRecord.mechanisms[0].qualifier).toEqual(SpfQualifier.PASS);
|
||||
|
||||
expect(parsedRecord.mechanisms[1].type).toEqual(SpfMechanismType.MX);
|
||||
expect(parsedRecord.mechanisms[1].qualifier).toEqual(SpfQualifier.PASS);
|
||||
|
||||
expect(parsedRecord.mechanisms[2].type).toEqual(SpfMechanismType.IP4);
|
||||
expect(parsedRecord.mechanisms[2].value).toEqual('192.168.0.1/24');
|
||||
|
||||
expect(parsedRecord.mechanisms[3].type).toEqual(SpfMechanismType.INCLUDE);
|
||||
expect(parsedRecord.mechanisms[3].value).toEqual('example.org');
|
||||
|
||||
expect(parsedRecord.mechanisms[4].type).toEqual(SpfMechanismType.ALL);
|
||||
expect(parsedRecord.mechanisms[4].qualifier).toEqual(SpfQualifier.SOFTFAIL);
|
||||
|
||||
// Test invalid record
|
||||
const invalidRecord = 'not-a-spf-record';
|
||||
const invalidParsed = spfVerifier.parseSpfRecord(invalidRecord);
|
||||
expect(invalidParsed).toBeNull();
|
||||
});
|
||||
|
||||
// DMARC Verifier Tests
|
||||
tap.test('DMARC Verifier - should parse DMARC record', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier(platformService.mtaService);
|
||||
|
||||
// Test valid DMARC record parsing
|
||||
const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com';
|
||||
const parsedRecord = dmarcVerifier.parseDmarcRecord(record);
|
||||
|
||||
expect(parsedRecord).toBeTruthy();
|
||||
expect(parsedRecord.version).toEqual('DMARC1');
|
||||
expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT);
|
||||
expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE);
|
||||
expect(parsedRecord.pct).toEqual(50);
|
||||
expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT);
|
||||
expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED);
|
||||
expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com');
|
||||
|
||||
// Test invalid record
|
||||
const invalidRecord = 'not-a-dmarc-record';
|
||||
const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord);
|
||||
expect(invalidParsed).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('DMARC Verifier - should verify DMARC alignment', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier(platformService.mtaService);
|
||||
|
||||
// Test email domains with DMARC alignment
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.net',
|
||||
subject: 'Test DMARC alignment',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
// Test when both SPF and DKIM pass with alignment
|
||||
const dmarcResult = await dmarcVerifier.verify(
|
||||
email,
|
||||
{ domain: 'example.com', result: true }, // SPF - aligned and passed
|
||||
{ domain: 'example.com', result: true } // DKIM - aligned and passed
|
||||
);
|
||||
|
||||
expect(dmarcResult).toBeTruthy();
|
||||
expect(dmarcResult.spfPassed).toEqual(true);
|
||||
expect(dmarcResult.dkimPassed).toEqual(true);
|
||||
expect(dmarcResult.spfDomainAligned).toEqual(true);
|
||||
expect(dmarcResult.dkimDomainAligned).toEqual(true);
|
||||
expect(dmarcResult.action).toEqual('pass');
|
||||
|
||||
// Test when neither SPF nor DKIM is aligned
|
||||
const dmarcResult2 = await dmarcVerifier.verify(
|
||||
email,
|
||||
{ domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned
|
||||
{ domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned
|
||||
);
|
||||
|
||||
// We can now see the actual DMARC result and update our expectations
|
||||
|
||||
expect(dmarcResult2).toBeTruthy();
|
||||
expect(dmarcResult2.spfPassed).toEqual(true);
|
||||
expect(dmarcResult2.dkimPassed).toEqual(true);
|
||||
expect(dmarcResult2.spfDomainAligned).toEqual(false);
|
||||
expect(dmarcResult2.dkimDomainAligned).toEqual(false);
|
||||
|
||||
// The test environment is returning a 'reject' policy - we can verify that
|
||||
expect(dmarcResult2.policyEvaluated).toEqual('reject');
|
||||
expect(dmarcResult2.actualPolicy).toEqual('reject');
|
||||
expect(dmarcResult2.action).toEqual('reject');
|
||||
});
|
||||
|
||||
tap.test('DMARC Verifier - should apply policy correctly', async () => {
|
||||
const dmarcVerifier = new DmarcVerifier(platformService.mtaService);
|
||||
|
||||
// Create test email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.net',
|
||||
subject: 'Test DMARC policy application',
|
||||
text: 'This is a test email'
|
||||
});
|
||||
|
||||
// Test pass action
|
||||
const passResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: true,
|
||||
dkimDomainAligned: true,
|
||||
spfPassed: true,
|
||||
dkimPassed: true,
|
||||
policyEvaluated: DmarcPolicy.NONE,
|
||||
actualPolicy: DmarcPolicy.NONE,
|
||||
appliedPercentage: 100,
|
||||
action: 'pass',
|
||||
details: 'DMARC passed'
|
||||
};
|
||||
|
||||
const passApplied = dmarcVerifier.applyPolicy(email, passResult);
|
||||
expect(passApplied).toEqual(true);
|
||||
expect(email.mightBeSpam).toEqual(false);
|
||||
expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed');
|
||||
|
||||
// Test quarantine action
|
||||
const quarantineResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: false,
|
||||
dkimDomainAligned: false,
|
||||
spfPassed: false,
|
||||
dkimPassed: false,
|
||||
policyEvaluated: DmarcPolicy.QUARANTINE,
|
||||
actualPolicy: DmarcPolicy.QUARANTINE,
|
||||
appliedPercentage: 100,
|
||||
action: 'quarantine',
|
||||
details: 'DMARC failed, policy=quarantine'
|
||||
};
|
||||
|
||||
// Reset email spam flag
|
||||
email.mightBeSpam = false;
|
||||
email.headers = {};
|
||||
|
||||
const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult);
|
||||
expect(quarantineApplied).toEqual(true);
|
||||
expect(email.mightBeSpam).toEqual(true);
|
||||
expect(email.headers['X-Spam-Flag']).toEqual('YES');
|
||||
expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine');
|
||||
|
||||
// Test reject action
|
||||
const rejectResult: any = {
|
||||
hasDmarc: true,
|
||||
spfDomainAligned: false,
|
||||
dkimDomainAligned: false,
|
||||
spfPassed: false,
|
||||
dkimPassed: false,
|
||||
policyEvaluated: DmarcPolicy.REJECT,
|
||||
actualPolicy: DmarcPolicy.REJECT,
|
||||
appliedPercentage: 100,
|
||||
action: 'reject',
|
||||
details: 'DMARC failed, policy=reject'
|
||||
};
|
||||
|
||||
// Reset email spam flag
|
||||
email.mightBeSpam = false;
|
||||
email.headers = {};
|
||||
|
||||
const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult);
|
||||
expect(rejectApplied).toEqual(false);
|
||||
expect(email.mightBeSpam).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('Cleanup test environment', async () => {
|
||||
await platformService.stop();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
274
test/test.errors.ts
Normal file
274
test/test.errors.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as errors from '../ts/errors/index.js';
|
||||
import {
|
||||
PlatformError,
|
||||
ValidationError,
|
||||
NetworkError,
|
||||
ResourceError,
|
||||
OperationError
|
||||
} from '../ts/errors/base.errors.js';
|
||||
import {
|
||||
ErrorSeverity,
|
||||
ErrorCategory,
|
||||
ErrorRecoverability
|
||||
} from '../ts/errors/error.codes.js';
|
||||
import {
|
||||
ErrorHandler
|
||||
} from '../ts/errors/error-handler.js';
|
||||
|
||||
// Test base error classes
|
||||
tap.test('Base error classes should set properties correctly', async () => {
|
||||
const message = 'Test error message';
|
||||
const code = 'TEST_ERROR_CODE';
|
||||
const context = {
|
||||
component: 'TestComponent',
|
||||
operation: 'testOperation',
|
||||
data: { foo: 'bar' }
|
||||
};
|
||||
|
||||
// Test PlatformError
|
||||
const platformError = new PlatformError(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
|
||||
expect(platformError.message).toEqual(message);
|
||||
expect(platformError.code).toEqual(code);
|
||||
expect(platformError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||
expect(platformError.category).toEqual(ErrorCategory.OPERATION);
|
||||
expect(platformError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||
expect(platformError.context?.component).toEqual(context.component);
|
||||
expect(platformError.context?.operation).toEqual(context.operation);
|
||||
expect(platformError.context?.data?.foo).toEqual('bar');
|
||||
expect(platformError.name).toEqual('PlatformError');
|
||||
|
||||
// Test ValidationError
|
||||
const validationError = new ValidationError(message, code, context);
|
||||
expect(validationError.category).toEqual(ErrorCategory.VALIDATION);
|
||||
expect(validationError.severity).toEqual(ErrorSeverity.LOW);
|
||||
|
||||
// Test NetworkError
|
||||
const networkError = new NetworkError(message, code, context);
|
||||
expect(networkError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||||
expect(networkError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||
expect(networkError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||
|
||||
// Test ResourceError
|
||||
const resourceError = new ResourceError(message, code, context);
|
||||
expect(resourceError.category).toEqual(ErrorCategory.RESOURCE);
|
||||
});
|
||||
|
||||
// Test error handler utility
|
||||
tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||
// Configure error handler
|
||||
ErrorHandler.configure({
|
||||
logErrors: false, // Disable for testing
|
||||
includeStacksInProd: false,
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
baseDelay: 100,
|
||||
maxDelay: 1000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Test converting regular Error to PlatformError
|
||||
const regularError = new Error('Something went wrong');
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
regularError,
|
||||
'PLATFORM_OPERATION_ERROR',
|
||||
{ component: 'TestHandler' }
|
||||
);
|
||||
|
||||
expect(platformError).toBeInstanceOf(PlatformError);
|
||||
expect(platformError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||
expect(platformError.context?.component).toEqual('TestHandler');
|
||||
|
||||
// Test formatting error for API response
|
||||
const formattedError = ErrorHandler.formatErrorForResponse(platformError, true);
|
||||
expect(formattedError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||
expect(formattedError.message).toEqual('An unexpected error occurred.');
|
||||
expect(formattedError.details?.rawMessage).toEqual('Something went wrong');
|
||||
|
||||
// Test executing a function with error handling
|
||||
let executed = false;
|
||||
try {
|
||||
await ErrorHandler.execute(async () => {
|
||||
executed = true;
|
||||
throw new Error('Execution failed');
|
||||
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||
expect(error.context.operation).toEqual('testExecution');
|
||||
}
|
||||
expect(executed).toEqual(true);
|
||||
|
||||
// Test executeWithRetry successful after retries
|
||||
let attempts = 0;
|
||||
const result = await ErrorHandler.executeWithRetry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary failure');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
'TEST_RETRY_ERROR',
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10, // Use small delay for tests
|
||||
retryableErrorPatterns: [/Temporary failure/], // Add pattern to make error retryable
|
||||
onRetry: (error, attempt, delay) => {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(attempt).toBeGreaterThan(0);
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual('success');
|
||||
expect(attempts).toEqual(3);
|
||||
|
||||
// Test executeWithRetry that fails after max attempts
|
||||
attempts = 0;
|
||||
try {
|
||||
await ErrorHandler.executeWithRetry(
|
||||
async () => {
|
||||
attempts++;
|
||||
throw new Error('Persistent failure');
|
||||
},
|
||||
'TEST_RETRY_ERROR',
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 10,
|
||||
retryableErrorPatterns: [/Persistent failure/] // Make error retryable so it tries all attempts
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(attempts).toEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
// Test retry utilities
|
||||
tap.test('Error retry utilities should work correctly', async () => {
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await errors.retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary error');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelay: 20,
|
||||
backoffFactor: 1.5,
|
||||
retryableErrors: [/Temporary/]
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
}
|
||||
|
||||
expect(attempts).toEqual(3);
|
||||
|
||||
// Test retry with non-retryable error
|
||||
attempts = 0;
|
||||
try {
|
||||
await errors.retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
throw new Error('Critical error');
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Temporary/] // Won't match "Critical"
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('Critical error');
|
||||
expect(attempts).toEqual(1); // Should only attempt once
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function that will reject first n times, then resolve
|
||||
interface FlakyFunction {
|
||||
(failTimes: number, result?: any): Promise<any>;
|
||||
counter: number;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const flaky: FlakyFunction = Object.assign(
|
||||
async function (failTimes: number, result: any = 'success'): Promise<any> {
|
||||
if (flaky.counter < failTimes) {
|
||||
flaky.counter++;
|
||||
throw new Error(`Flaky failure ${flaky.counter}`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{
|
||||
counter: 0,
|
||||
reset: () => { flaky.counter = 0; }
|
||||
}
|
||||
);
|
||||
|
||||
// Test error wrapping and retry combination
|
||||
tap.test('Error handling can be combined with retry for robust operations', async () => {
|
||||
// Reset counter for the test
|
||||
flaky.reset();
|
||||
|
||||
// Create a wrapped version of the flaky function
|
||||
const wrapped = errors.withErrorHandling(
|
||||
() => flaky(2, 'wrapped success'),
|
||||
'TEST_WRAPPED_ERROR',
|
||||
{ component: 'TestComponent' }
|
||||
);
|
||||
|
||||
// Execute with retry
|
||||
const result = await errors.retry(
|
||||
wrapped,
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Flaky failure/]
|
||||
}
|
||||
);
|
||||
expect(result).toEqual('wrapped success');
|
||||
expect(flaky.counter).toEqual(2);
|
||||
|
||||
// Reset and test failure case
|
||||
flaky.reset();
|
||||
|
||||
try {
|
||||
await errors.retry(
|
||||
() => flaky(5, 'never reached'),
|
||||
{
|
||||
maxRetries: 2, // Only retry twice, but we need 5 attempts to succeed
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Flaky failure/] // Add pattern to make it retry
|
||||
}
|
||||
);
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
} catch (error) {
|
||||
expect(error.message).toContain('Flaky failure');
|
||||
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,100 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { SzPlatformService } from '../ts/platformservice.js';
|
||||
import { MtaService } from '../ts/mta/classes.mta.js';
|
||||
import { EmailService } from '../ts/email/classes.emailservice.js';
|
||||
import { BounceManager } from '../ts/email/classes.bouncemanager.js';
|
||||
import DcRouter from '../ts/dcrouter/classes.dcrouter.js';
|
||||
|
||||
// Test the new integration architecture
|
||||
tap.test('should be able to create an independent MTA service', async (tools) => {
|
||||
// Create an independent MTA service
|
||||
const mta = new MtaService(undefined, {
|
||||
smtp: {
|
||||
port: 10025, // Use a different port for testing
|
||||
hostname: 'test.example.com'
|
||||
}
|
||||
});
|
||||
|
||||
// Verify it was created properly without a platform service reference
|
||||
expect(mta).toBeTruthy();
|
||||
expect(mta.platformServiceRef).toBeUndefined();
|
||||
|
||||
// Even without a platform service, it should have its own SMTP rule engine
|
||||
expect(mta.smtpRuleEngine).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should be able to create an EmailService with an existing MTA', async (tools) => {
|
||||
// Create a platform service first
|
||||
const platformService = new SzPlatformService();
|
||||
|
||||
// Create a shared bounce manager
|
||||
const bounceManager = new BounceManager();
|
||||
|
||||
// Create an independent MTA service
|
||||
const mta = new MtaService(undefined, {
|
||||
smtp: {
|
||||
port: 10025, // Use a different port for testing
|
||||
}
|
||||
});
|
||||
|
||||
// Manually set the bounce manager for testing
|
||||
// @ts-ignore - adding property for testing
|
||||
mta.bounceManager = bounceManager;
|
||||
|
||||
// Create an email service that uses the independent MTA
|
||||
// @ts-ignore - passing a third argument to the constructor
|
||||
const emailService = new EmailService(platformService, {}, mta);
|
||||
|
||||
// Manually set the mtaService property
|
||||
emailService.mtaService = mta;
|
||||
|
||||
// Verify relationships
|
||||
expect(emailService.mtaService === mta).toBeTrue();
|
||||
expect(emailService.bounceManager).toBeTruthy();
|
||||
|
||||
// MTA should not have a direct platform service reference
|
||||
expect(mta.platformServiceRef).toBeUndefined();
|
||||
|
||||
// But it should have access to bounce manager
|
||||
// @ts-ignore - accessing property for testing
|
||||
expect(mta.bounceManager === bounceManager).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('MTA service should have SMTP rule engine', async (tools) => {
|
||||
// Create an independent MTA service
|
||||
const mta = new MtaService(undefined, {
|
||||
smtp: {
|
||||
port: 10025, // Use a different port for testing
|
||||
}
|
||||
});
|
||||
|
||||
// Verify the MTA has an SMTP rule engine
|
||||
expect(mta.smtpRuleEngine).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('platform service should support having an MTA service', async (tools) => {
|
||||
// Create a platform service with default config
|
||||
const platformService = new SzPlatformService();
|
||||
|
||||
// Create MTA - don't await start() to avoid binding to ports
|
||||
platformService.mtaService = new MtaService(platformService, {
|
||||
smtp: {
|
||||
port: 10025, // Use a different port for testing
|
||||
}
|
||||
});
|
||||
|
||||
// Create email service using the platform
|
||||
platformService.emailService = new EmailService(platformService);
|
||||
|
||||
// Verify the MTA has a reference to the platform service
|
||||
expect(platformService.mtaService).toBeTruthy();
|
||||
expect(platformService.mtaService.platformServiceRef).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
// Export for tapbundle execution
|
||||
export default tap.start();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { IPReputationChecker, ReputationThreshold, IPType } from '../ts/security/classes.ipreputationchecker.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
@@ -6,8 +6,8 @@ import * as plugins from '../ts/plugins.js';
|
||||
const originalDnsResolve = plugins.dns.promises.resolve;
|
||||
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
|
||||
|
||||
// Setup mock DNS resolver
|
||||
plugins.dns.promises.resolve = async (hostname: string) => {
|
||||
// Setup mock DNS resolver with proper typing
|
||||
(plugins.dns.promises as any).resolve = async (hostname: string) => {
|
||||
return mockDnsResolveImpl(hostname);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js';
|
||||
|
||||
// Cleanup any temporary test data
|
||||
const cleanupTestData = () => {
|
||||
const warmupDataPath = plugins.path.join(paths.dataDir, 'warmup');
|
||||
if (plugins.fs.existsSync(warmupDataPath)) {
|
||||
// Remove the directory recursively using fs instead of smartfile
|
||||
plugins.fs.rmSync(warmupDataPath, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset the singleton instance between tests
|
||||
const resetSingleton = () => {
|
||||
// @ts-ignore - accessing private static field for testing
|
||||
IPWarmupManager.instance = null;
|
||||
};
|
||||
|
||||
// Before running any tests
|
||||
tap.test('setup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
// Test initialization of IPWarmupManager
|
||||
tap.test('should initialize IPWarmupManager with default settings', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance();
|
||||
|
||||
expect(ipWarmupManager).toBeTruthy();
|
||||
expect(typeof ipWarmupManager.getBestIPForSending).toEqual('function');
|
||||
expect(typeof ipWarmupManager.canSendMoreToday).toEqual('function');
|
||||
expect(typeof ipWarmupManager.getStageCount).toEqual('function');
|
||||
expect(typeof ipWarmupManager.setActiveAllocationPolicy).toEqual('function');
|
||||
});
|
||||
|
||||
// Test initialization with custom settings
|
||||
tap.test('should initialize IPWarmupManager with custom settings', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com', 'test.com'],
|
||||
fallbackPercentage: 75
|
||||
});
|
||||
|
||||
// Test setting allocation policy
|
||||
ipWarmupManager.setActiveAllocationPolicy('roundRobin');
|
||||
|
||||
// Get best IP for sending
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Check if we can send more today
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday('192.168.1.1');
|
||||
|
||||
// Check stage count
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(typeof stageCount).toEqual('number');
|
||||
});
|
||||
|
||||
// Test IP allocation policies
|
||||
tap.test('should allocate IPs using balanced policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Use getBestIPForSending multiple times and check if all IPs are used
|
||||
const usedIPs = new Set();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
if (ip) usedIPs.add(ip);
|
||||
}
|
||||
|
||||
// We should use at least 2 different IPs with balanced policy
|
||||
expect(usedIPs.size >= 2).toBeTrue();
|
||||
});
|
||||
|
||||
// Test round robin allocation policy
|
||||
tap.test('should allocate IPs using round robin policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('roundRobin');
|
||||
|
||||
// First few IPs should rotate through the available IPs
|
||||
const firstIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const secondIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const thirdIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Round robin should give us different IPs for consecutive calls
|
||||
expect(firstIP !== secondIP).toBeTrue();
|
||||
|
||||
// With 3 IPs, the fourth call should cycle back to one of the IPs
|
||||
const fourthIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// Check that the fourth IP is one of the 3 valid IPs
|
||||
expect(['192.168.1.1', '192.168.1.2', '192.168.1.3'].includes(fourthIP)).toBeTrue();
|
||||
});
|
||||
|
||||
// Test dedicated domain allocation policy
|
||||
tap.test('should allocate IPs using dedicated domain policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com', 'other.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
ipWarmupManager.setActiveAllocationPolicy('dedicated');
|
||||
|
||||
// Instead of mapDomainToIP which doesn't exist, we'll simulate domain mapping
|
||||
// by making dedicated calls per domain - we can't call the internal method directly
|
||||
|
||||
// Each domain should get its dedicated IP
|
||||
const exampleIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const testIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@test.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'test.com'
|
||||
});
|
||||
|
||||
const otherIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@other.com',
|
||||
to: ['recipient@gmail.com'],
|
||||
domain: 'other.com'
|
||||
});
|
||||
|
||||
// Since we're not actually mapping domains to IPs, we can only test if they return valid IPs
|
||||
// The original assertions have been modified since we can't guarantee which IP will be returned
|
||||
expect(exampleIP).toBeTruthy();
|
||||
expect(testIP).toBeTruthy();
|
||||
expect(otherIP).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test daily sending limits
|
||||
tap.test('should enforce daily sending limits', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1'],
|
||||
targetDomains: ['example.com']
|
||||
// Remove allocationPolicy which is not in the interface
|
||||
});
|
||||
|
||||
// Override the warmup stage for testing
|
||||
// @ts-ignore - accessing private method for testing
|
||||
ipWarmupManager.warmupStatuses.set('192.168.1.1', {
|
||||
ipAddress: '192.168.1.1',
|
||||
isActive: true,
|
||||
currentStage: 1,
|
||||
startDate: new Date(),
|
||||
currentStageStartDate: new Date(),
|
||||
targetCompletionDate: new Date(),
|
||||
currentDailyAllocation: 5,
|
||||
sentInCurrentStage: 0,
|
||||
totalSent: 0,
|
||||
dailyStats: [],
|
||||
metrics: {
|
||||
openRate: 0,
|
||||
bounceRate: 0,
|
||||
complaintRate: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Set a very low daily limit for testing
|
||||
// @ts-ignore - accessing private method for testing
|
||||
ipWarmupManager.config.stages = [
|
||||
{ stage: 1, maxDailyVolume: 5, durationDays: 5, targetMetrics: { maxBounceRate: 8, minOpenRate: 15 } }
|
||||
];
|
||||
|
||||
// First pass: should be able to get an IP
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(ip === '192.168.1.1').toBeTrue();
|
||||
|
||||
// Record 5 sends to reach the daily limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ipWarmupManager.recordSend('192.168.1.1');
|
||||
}
|
||||
|
||||
// Check if we can send more today
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday('192.168.1.1');
|
||||
expect(canSendMore).toEqual(false);
|
||||
|
||||
// After reaching limit, getBestIPForSending should return null
|
||||
// since there are no available IPs
|
||||
const sixthIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(sixthIP === null).toBeTrue();
|
||||
});
|
||||
|
||||
// Test recording sends
|
||||
tap.test('should record send events correctly', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com'],
|
||||
});
|
||||
|
||||
// Set allocation policy
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
// Get an IP for sending
|
||||
const ip = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
// If we got an IP, record some sends
|
||||
if (ip) {
|
||||
// Record a few sends
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ipWarmupManager.recordSend(ip);
|
||||
}
|
||||
|
||||
// Check if we can still send more
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(ip);
|
||||
expect(typeof canSendMore).toEqual('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
// Test that DedicatedDomainPolicy assigns IPs correctly
|
||||
tap.test('should assign IPs using dedicated domain policy', async () => {
|
||||
resetSingleton();
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2', '192.168.1.3'],
|
||||
targetDomains: ['example.com', 'test.com', 'other.com']
|
||||
});
|
||||
|
||||
// Set allocation policy to dedicated domains
|
||||
ipWarmupManager.setActiveAllocationPolicy('dedicated');
|
||||
|
||||
// Check allocation by querying for different domains
|
||||
const ip1 = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
const ip2 = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@test.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'test.com'
|
||||
});
|
||||
|
||||
// If we got IPs, they should be consistently assigned
|
||||
if (ip1 && ip2) {
|
||||
// Requesting the same domain again should return the same IP
|
||||
const ip1again = ipWarmupManager.getBestIPForSending({
|
||||
from: 'another@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
expect(ip1again === ip1).toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
// After all tests, clean up
|
||||
tap.test('cleanup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
131
test/test.jwt-auth.ts
Normal file
131
test/test.jwt-auth.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity).toHaveProperty('jwt');
|
||||
expect(response.identity).toHaveProperty('userId');
|
||||
expect(response.identity).toHaveProperty('name');
|
||||
expect(response.identity).toHaveProperty('expiresAt');
|
||||
expect(response.identity).toHaveProperty('role');
|
||||
expect(response.identity.role).toEqual('admin');
|
||||
|
||||
identity = response.identity;
|
||||
console.log('JWT:', identity.jwt);
|
||||
});
|
||||
|
||||
tap.test('should verify valid JWT identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity: {
|
||||
...identity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should verify JWT matches identity data', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
// The response should contain the same identity data as the JWT
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLogout'
|
||||
);
|
||||
|
||||
const response = await logoutRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('success');
|
||||
expect(response.success).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should reject wrong credentials', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
let errorOccurred = false;
|
||||
try {
|
||||
await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
} catch (error) {
|
||||
errorOccurred = true;
|
||||
// TypedResponseError is thrown
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(errorOccurred).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,66 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
||||
import { IPWarmupManager } from '../ts/deliverability/classes.ipwarmupmanager.js';
|
||||
|
||||
/**
|
||||
* Basic test to check if our integrated classes work correctly
|
||||
*/
|
||||
tap.test('verify that SenderReputationMonitor and IPWarmupManager are functioning', async (tools) => {
|
||||
// Create instances of both classes
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
const ipWarmupManager = IPWarmupManager.getInstance({
|
||||
enabled: true,
|
||||
ipAddresses: ['192.168.1.1', '192.168.1.2'],
|
||||
targetDomains: ['example.com']
|
||||
});
|
||||
|
||||
// Test SenderReputationMonitor
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
|
||||
const reputationData = reputationMonitor.getReputationData('example.com');
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
|
||||
// Basic checks
|
||||
expect(reputationData).toBeTruthy();
|
||||
expect(summary.length).toBeGreaterThan(0);
|
||||
|
||||
// Add and remove domains
|
||||
reputationMonitor.addDomain('test.com');
|
||||
reputationMonitor.removeDomain('test.com');
|
||||
|
||||
// Test IPWarmupManager
|
||||
ipWarmupManager.setActiveAllocationPolicy('balanced');
|
||||
|
||||
const bestIP = ipWarmupManager.getBestIPForSending({
|
||||
from: 'test@example.com',
|
||||
to: ['recipient@test.com'],
|
||||
domain: 'example.com'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
ipWarmupManager.recordSend(bestIP);
|
||||
const canSendMore = ipWarmupManager.canSendMoreToday(bestIP);
|
||||
expect(canSendMore !== undefined).toBeTrue();
|
||||
}
|
||||
|
||||
const stageCount = ipWarmupManager.getStageCount();
|
||||
expect(stageCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op - just to make sure everything is cleaned up properly
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
84
test/test.opsserver-api.ts
Normal file
84
test/test.opsserver-api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
const response = await healthRequest.fire({
|
||||
detailed: false
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
expect(response.health.services).toHaveProperty('OpsServer');
|
||||
});
|
||||
|
||||
tap.test('should respond to server statistics request', async () => {
|
||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getServerStatistics'
|
||||
);
|
||||
|
||||
const response = await statsRequest.fire({
|
||||
includeHistory: false
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('stats');
|
||||
expect(response.stats).toHaveProperty('uptime');
|
||||
expect(response.stats).toHaveProperty('cpuUsage');
|
||||
expect(response.stats).toHaveProperty('memoryUsage');
|
||||
});
|
||||
|
||||
tap.test('should respond to configuration request', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
const response = await configRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
});
|
||||
|
||||
tap.test('should handle log retrieval request', async () => {
|
||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getRecentLogs'
|
||||
);
|
||||
|
||||
const response = await logsRequest.fire({
|
||||
limit: 10
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('logs');
|
||||
expect(response).toHaveProperty('total');
|
||||
expect(response).toHaveProperty('hasMore');
|
||||
expect(response.logs).toBeArray();
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
120
test/test.protected-endpoint.ts
Normal file
120
test/test.protected-endpoint.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
console.log('Admin logged in with JWT');
|
||||
});
|
||||
|
||||
tap.test('should allow admin to verify identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
console.log('Admin identity verified successfully');
|
||||
});
|
||||
|
||||
tap.test('should reject verify identity without identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
try {
|
||||
await verifyRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Successfully rejected request without identity');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
try {
|
||||
await verifyRequest.fire({
|
||||
identity: {
|
||||
...adminIdentity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
},
|
||||
});
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Successfully rejected request with invalid JWT');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should allow access to public endpoints without auth', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
// No identity provided
|
||||
const response = await healthRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
console.log('Public endpoint accessible without auth');
|
||||
});
|
||||
|
||||
tap.test('should allow read-only config access', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
// Config is read-only and doesn't require auth
|
||||
const response = await configRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
console.log('Configuration read successfully');
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,141 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import { RateLimiter } from '../ts/mta/classes.ratelimiter.js';
|
||||
|
||||
tap.test('RateLimiter - should be instantiable', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 10,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
expect(limiter).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should allow requests within rate limit', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 5,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Should allow 5 requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
}
|
||||
|
||||
// 6th request should be denied
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should enforce per-key limits', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 3,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Should allow 3 requests for key1
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(limiter.isAllowed('key1')).toEqual(true);
|
||||
}
|
||||
|
||||
// 4th request for key1 should be denied
|
||||
expect(limiter.isAllowed('key1')).toEqual(false);
|
||||
|
||||
// But key2 should still be allowed
|
||||
expect(limiter.isAllowed('key2')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should refill tokens over time', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 2,
|
||||
periodMs: 100, // Short period for testing
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Use all tokens
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Wait for refill
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Should have tokens again
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should support burst allowance', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 2,
|
||||
periodMs: 100,
|
||||
perKey: true,
|
||||
burstTokens: 2, // Allow 2 extra tokens for bursts
|
||||
initialTokens: 4 // Start with max + burst tokens
|
||||
});
|
||||
|
||||
// Should allow 4 requests (2 regular + 2 burst)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
}
|
||||
|
||||
// 5th request should be denied
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Wait for refill
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Should have 2 tokens again (rate-limited to normal max, not burst)
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
|
||||
// 3rd request after refill should fail (only normal max is refilled, not burst)
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should return correct stats', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 10,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Make some requests
|
||||
limiter.isAllowed('test');
|
||||
limiter.isAllowed('test');
|
||||
limiter.isAllowed('test');
|
||||
|
||||
// Get stats
|
||||
const stats = limiter.getStats('test');
|
||||
|
||||
expect(stats.remaining).toEqual(7);
|
||||
expect(stats.limit).toEqual(10);
|
||||
expect(stats.allowed).toEqual(3);
|
||||
expect(stats.denied).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('RateLimiter - should reset limits', async () => {
|
||||
const limiter = new RateLimiter({
|
||||
maxPerPeriod: 3,
|
||||
periodMs: 1000,
|
||||
perKey: true
|
||||
});
|
||||
|
||||
// Use all tokens
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
expect(limiter.isAllowed('test')).toEqual(false);
|
||||
|
||||
// Reset
|
||||
limiter.reset('test');
|
||||
|
||||
// Should have tokens again
|
||||
expect(limiter.isAllowed('test')).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,243 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { SenderReputationMonitor } from '../ts/deliverability/classes.senderreputationmonitor.js';
|
||||
|
||||
// Cleanup any temporary test data
|
||||
const cleanupTestData = () => {
|
||||
const reputationDataPath = plugins.path.join(paths.dataDir, 'reputation');
|
||||
if (plugins.fs.existsSync(reputationDataPath)) {
|
||||
// Remove the directory recursively using fs instead of smartfile
|
||||
plugins.fs.rmSync(reputationDataPath, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to reset the singleton instance between tests
|
||||
const resetSingleton = () => {
|
||||
// @ts-ignore - accessing private static field for testing
|
||||
SenderReputationMonitor.instance = null;
|
||||
};
|
||||
|
||||
// Before running any tests
|
||||
tap.test('setup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
// Test initialization of SenderReputationMonitor
|
||||
tap.test('should initialize SenderReputationMonitor with default settings', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance();
|
||||
|
||||
expect(reputationMonitor).toBeTruthy();
|
||||
// Check if the object has the expected methods
|
||||
expect(typeof reputationMonitor.recordSendEvent).toEqual('function');
|
||||
expect(typeof reputationMonitor.getReputationData).toEqual('function');
|
||||
expect(typeof reputationMonitor.getReputationSummary).toEqual('function');
|
||||
});
|
||||
|
||||
// Test initialization with custom settings
|
||||
tap.test('should initialize SenderReputationMonitor with custom settings', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com', 'test.com'],
|
||||
updateFrequency: 12 * 60 * 60 * 1000, // 12 hours
|
||||
alertThresholds: {
|
||||
minReputationScore: 80,
|
||||
maxComplaintRate: 0.05
|
||||
}
|
||||
});
|
||||
|
||||
// Test adding domains
|
||||
reputationMonitor.addDomain('newdomain.com');
|
||||
|
||||
// Test retrieving reputation data
|
||||
const data = reputationMonitor.getReputationData('example.com');
|
||||
expect(data).toBeTruthy();
|
||||
expect(data.domain).toEqual('example.com');
|
||||
});
|
||||
|
||||
// Test recording and tracking send events
|
||||
tap.test('should record send events and update metrics', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record a series of events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: true, count: 3 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: false, count: 2 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'complaint', count: 1 });
|
||||
|
||||
// Check metrics
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
expect(metrics.volume.hardBounces).toEqual(3);
|
||||
expect(metrics.volume.softBounces).toEqual(2);
|
||||
expect(metrics.complaints.total).toEqual(1);
|
||||
});
|
||||
|
||||
// Test reputation score calculation
|
||||
tap.test('should calculate reputation scores correctly', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['high.com', 'medium.com', 'low.com']
|
||||
});
|
||||
|
||||
// Record events for different domains
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'delivered', count: 990 });
|
||||
reputationMonitor.recordSendEvent('high.com', { type: 'open', count: 500 });
|
||||
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'delivered', count: 950 });
|
||||
reputationMonitor.recordSendEvent('medium.com', { type: 'open', count: 300 });
|
||||
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'delivered', count: 850 });
|
||||
reputationMonitor.recordSendEvent('low.com', { type: 'open', count: 100 });
|
||||
|
||||
// Get reputation summary
|
||||
const summary = reputationMonitor.getReputationSummary();
|
||||
expect(Array.isArray(summary)).toBeTrue();
|
||||
expect(summary.length >= 3).toBeTrue();
|
||||
|
||||
// Check that domains are included in the summary
|
||||
const domains = summary.map(item => item.domain);
|
||||
expect(domains.includes('high.com')).toBeTrue();
|
||||
expect(domains.includes('medium.com')).toBeTrue();
|
||||
expect(domains.includes('low.com')).toBeTrue();
|
||||
});
|
||||
|
||||
// Test adding and removing domains
|
||||
tap.test('should add and remove domains for monitoring', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Add a new domain
|
||||
reputationMonitor.addDomain('newdomain.com');
|
||||
|
||||
// Record data for the new domain
|
||||
reputationMonitor.recordSendEvent('newdomain.com', { type: 'sent', count: 50 });
|
||||
|
||||
// Check that data was recorded for the new domain
|
||||
const metrics = reputationMonitor.getReputationData('newdomain.com');
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.volume.sent).toEqual(50);
|
||||
|
||||
// Remove a domain
|
||||
reputationMonitor.removeDomain('newdomain.com');
|
||||
|
||||
// Check that data is no longer available
|
||||
const removedMetrics = reputationMonitor.getReputationData('newdomain.com');
|
||||
expect(removedMetrics === null).toBeTrue();
|
||||
});
|
||||
|
||||
// Test handling open and click events
|
||||
tap.test('should track engagement metrics correctly', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record basic sending metrics
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 950 });
|
||||
|
||||
// Record engagement events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'open', count: 500 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'click', count: 250 });
|
||||
|
||||
// Check engagement metrics
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(metrics.engagement.opens).toEqual(500);
|
||||
expect(metrics.engagement.clicks).toEqual(250);
|
||||
expect(typeof metrics.engagement.openRate).toEqual('number');
|
||||
expect(typeof metrics.engagement.clickRate).toEqual('number');
|
||||
});
|
||||
|
||||
// Test historical data tracking
|
||||
tap.test('should store historical reputation data', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record events over multiple days
|
||||
const today = new Date();
|
||||
|
||||
// Record data
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 1000 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 950 });
|
||||
|
||||
// Get metrics data
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
// Check that historical data exists
|
||||
expect(metrics.historical).toBeTruthy();
|
||||
expect(metrics.historical.reputationScores).toBeTruthy();
|
||||
|
||||
// Check that daily send volume is tracked
|
||||
expect(metrics.volume.dailySendVolume).toBeTruthy();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
expect(metrics.volume.dailySendVolume[todayStr]).toEqual(1000);
|
||||
});
|
||||
|
||||
// Test event recording for different event types
|
||||
tap.test('should correctly handle different event types', async () => {
|
||||
resetSingleton();
|
||||
const reputationMonitor = SenderReputationMonitor.getInstance({
|
||||
enabled: true,
|
||||
domains: ['example.com']
|
||||
});
|
||||
|
||||
// Record different types of events
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'sent', count: 100 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'delivered', count: 95 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: true, count: 3 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'bounce', hardBounce: false, count: 2 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'complaint', receivingDomain: 'gmail.com', count: 1 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'open', count: 50 });
|
||||
reputationMonitor.recordSendEvent('example.com', { type: 'click', count: 25 });
|
||||
|
||||
// Check metrics for different event types
|
||||
const metrics = reputationMonitor.getReputationData('example.com');
|
||||
|
||||
// Check volume metrics
|
||||
expect(metrics.volume.sent).toEqual(100);
|
||||
expect(metrics.volume.delivered).toEqual(95);
|
||||
expect(metrics.volume.hardBounces).toEqual(3);
|
||||
expect(metrics.volume.softBounces).toEqual(2);
|
||||
|
||||
// Check complaint metrics
|
||||
expect(metrics.complaints.total).toEqual(1);
|
||||
expect(metrics.complaints.topDomains[0].domain).toEqual('gmail.com');
|
||||
|
||||
// Check engagement metrics
|
||||
expect(metrics.engagement.opens).toEqual(50);
|
||||
expect(metrics.engagement.clicks).toEqual(25);
|
||||
});
|
||||
|
||||
// After all tests, clean up
|
||||
tap.test('cleanup', async () => {
|
||||
cleanupTestData();
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,248 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
|
||||
// Import the components we want to test
|
||||
import { EmailValidator } from '../ts/email/classes.emailvalidator.js';
|
||||
import { TemplateManager } from '../ts/email/classes.templatemanager.js';
|
||||
import { Email } from '../ts/mta/classes.email.js';
|
||||
|
||||
// Ensure test directories exist
|
||||
paths.ensureDirectories();
|
||||
|
||||
tap.test('EmailValidator - should validate email formats correctly', async (tools) => {
|
||||
const validator = new EmailValidator();
|
||||
|
||||
// Test valid email formats
|
||||
expect(validator.isValidFormat('user@example.com')).toBeTrue();
|
||||
expect(validator.isValidFormat('firstname.lastname@example.com')).toBeTrue();
|
||||
expect(validator.isValidFormat('user+tag@example.com')).toBeTrue();
|
||||
|
||||
// Test invalid email formats
|
||||
expect(validator.isValidFormat('user@')).toBeFalse();
|
||||
expect(validator.isValidFormat('@example.com')).toBeFalse();
|
||||
expect(validator.isValidFormat('user@example')).toBeFalse();
|
||||
expect(validator.isValidFormat('user.example.com')).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('EmailValidator - should perform comprehensive validation', async (tools) => {
|
||||
const validator = new EmailValidator();
|
||||
|
||||
// Test basic validation (syntax-only)
|
||||
const basicResult = await validator.validate('user@example.com', { checkSyntaxOnly: true });
|
||||
expect(basicResult.isValid).toBeTrue();
|
||||
expect(basicResult.details.formatValid).toBeTrue();
|
||||
|
||||
// We can't reliably test MX validation in all environments, but the function should run
|
||||
const mxResult = await validator.validate('user@example.com', { checkMx: true });
|
||||
expect(typeof mxResult.isValid).toEqual('boolean');
|
||||
expect(typeof mxResult.hasMx).toEqual('boolean');
|
||||
});
|
||||
|
||||
tap.test('EmailValidator - should detect invalid emails', async (tools) => {
|
||||
const validator = new EmailValidator();
|
||||
|
||||
const invalidResult = await validator.validate('invalid@@example.com', { checkSyntaxOnly: true });
|
||||
expect(invalidResult.isValid).toBeFalse();
|
||||
expect(invalidResult.details.formatValid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('TemplateManager - should register and retrieve templates', async (tools) => {
|
||||
const templateManager = new TemplateManager({
|
||||
from: 'test@example.com'
|
||||
});
|
||||
|
||||
// Register a custom template
|
||||
templateManager.registerTemplate({
|
||||
id: 'test-template',
|
||||
name: 'Test Template',
|
||||
description: 'A test template',
|
||||
from: 'test@example.com',
|
||||
subject: 'Test Subject: {{name}}',
|
||||
bodyHtml: '<p>Hello, {{name}}!</p>',
|
||||
bodyText: 'Hello, {{name}}!',
|
||||
category: 'test'
|
||||
});
|
||||
|
||||
// Get the template back
|
||||
const template = templateManager.getTemplate('test-template');
|
||||
expect(template).toBeTruthy();
|
||||
expect(template.id).toEqual('test-template');
|
||||
expect(template.subject).toEqual('Test Subject: {{name}}');
|
||||
|
||||
// List templates
|
||||
const templates = templateManager.listTemplates();
|
||||
expect(templates.length > 0).toBeTrue();
|
||||
expect(templates.some(t => t.id === 'test-template')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('TemplateManager - should create smartmail from template', async (tools) => {
|
||||
const templateManager = new TemplateManager({
|
||||
from: 'test@example.com'
|
||||
});
|
||||
|
||||
// Register a template
|
||||
templateManager.registerTemplate({
|
||||
id: 'welcome-test',
|
||||
name: 'Welcome Test',
|
||||
description: 'A welcome test template',
|
||||
from: 'welcome@example.com',
|
||||
subject: 'Welcome, {{name}}!',
|
||||
bodyHtml: '<p>Hello, {{name}}! Welcome to our service.</p>',
|
||||
bodyText: 'Hello, {{name}}! Welcome to our service.',
|
||||
category: 'test'
|
||||
});
|
||||
|
||||
// Create smartmail from template
|
||||
const smartmail = await templateManager.createSmartmail('welcome-test', {
|
||||
name: 'John Doe'
|
||||
});
|
||||
|
||||
expect(smartmail).toBeTruthy();
|
||||
expect(smartmail.options.from).toEqual('welcome@example.com');
|
||||
expect(smartmail.getSubject()).toEqual('Welcome, John Doe!');
|
||||
expect(smartmail.getBody(true).indexOf('Hello, John Doe!') > -1).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Email - should handle template variables', async (tools) => {
|
||||
// Create email with variables
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Hello {{name}}!',
|
||||
text: 'Welcome, {{name}}! Your order #{{orderId}} has been processed.',
|
||||
html: '<p>Welcome, <strong>{{name}}</strong>! Your order #{{orderId}} has been processed.</p>',
|
||||
variables: {
|
||||
name: 'John Doe',
|
||||
orderId: '12345'
|
||||
}
|
||||
});
|
||||
|
||||
// Test variable substitution
|
||||
expect(email.getSubjectWithVariables()).toEqual('Hello John Doe!');
|
||||
expect(email.getTextWithVariables()).toEqual('Welcome, John Doe! Your order #12345 has been processed.');
|
||||
expect(email.getHtmlWithVariables().indexOf('<strong>John Doe</strong>') > -1).toBeTrue();
|
||||
|
||||
// Test with additional variables
|
||||
const additionalVars = {
|
||||
name: 'Jane Smith', // Override existing variable
|
||||
status: 'shipped' // Add new variable
|
||||
};
|
||||
|
||||
expect(email.getSubjectWithVariables(additionalVars)).toEqual('Hello Jane Smith!');
|
||||
|
||||
// Add a new variable
|
||||
email.setVariable('trackingNumber', 'TRK123456');
|
||||
expect(email.getTextWithVariables().indexOf('12345') > -1).toBeTrue();
|
||||
|
||||
// Update multiple variables at once
|
||||
email.setVariables({
|
||||
orderId: '67890',
|
||||
status: 'delivered'
|
||||
});
|
||||
|
||||
expect(email.getTextWithVariables().indexOf('67890') > -1).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('Email and Smartmail compatibility - should convert between formats', async (tools) => {
|
||||
// Create a Smartmail instance
|
||||
const smartmail = new plugins.smartmail.Smartmail({
|
||||
from: 'smartmail@example.com',
|
||||
subject: 'Test Subject',
|
||||
body: '<p>This is a test email.</p>',
|
||||
creationObjectRef: {
|
||||
orderId: '12345'
|
||||
}
|
||||
});
|
||||
|
||||
// Add recipient and attachment
|
||||
smartmail.addRecipient('recipient@example.com');
|
||||
|
||||
const attachment = await plugins.smartfile.SmartFile.fromString(
|
||||
'test.txt',
|
||||
'This is a test attachment',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
smartmail.addAttachment(attachment);
|
||||
|
||||
// Convert to Email
|
||||
const resolvedSmartmail = await smartmail;
|
||||
const email = Email.fromSmartmail(resolvedSmartmail);
|
||||
|
||||
// Verify first conversion (Smartmail to Email)
|
||||
expect(email.from).toEqual('smartmail@example.com');
|
||||
expect(email.to.indexOf('recipient@example.com') > -1).toBeTrue();
|
||||
expect(email.subject).toEqual('Test Subject');
|
||||
expect(email.html?.indexOf('This is a test email') > -1).toBeTrue();
|
||||
expect(email.attachments.length).toEqual(1);
|
||||
|
||||
// Convert back to Smartmail
|
||||
const convertedSmartmail = await email.toSmartmail();
|
||||
|
||||
// Verify second conversion (Email back to Smartmail) with simplified assertions
|
||||
expect(convertedSmartmail.options.from).toEqual('smartmail@example.com');
|
||||
expect(Array.isArray(convertedSmartmail.options.to)).toBeTrue();
|
||||
expect(convertedSmartmail.options.to.length).toEqual(1);
|
||||
expect(convertedSmartmail.getSubject()).toEqual('Test Subject');
|
||||
expect(convertedSmartmail.getBody(true).indexOf('This is a test email') > -1).toBeTrue();
|
||||
expect(convertedSmartmail.attachments.length).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('Email - should validate email addresses', async (tools) => {
|
||||
// Attempt to create an email with invalid addresses
|
||||
let errorThrown = false;
|
||||
|
||||
try {
|
||||
const email = new Email({
|
||||
from: 'invalid-email',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test',
|
||||
text: 'Test'
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message.indexOf('Invalid sender email address') > -1).toBeTrue();
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
|
||||
// Attempt with invalid recipient
|
||||
errorThrown = false;
|
||||
|
||||
try {
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'invalid-recipient',
|
||||
subject: 'Test',
|
||||
text: 'Test'
|
||||
});
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message.indexOf('Invalid recipient email address') > -1).toBeTrue();
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
|
||||
// Valid email should not throw
|
||||
let validEmail: Email;
|
||||
try {
|
||||
validEmail = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test',
|
||||
text: 'Test'
|
||||
});
|
||||
|
||||
expect(validEmail).toBeTruthy();
|
||||
expect(validEmail.from).toEqual('sender@example.com');
|
||||
} catch (error) {
|
||||
expect(error === undefined).toBeTrue(); // This should not happen
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
tap.stopForcefully();
|
||||
})
|
||||
|
||||
export default tap.start();
|
||||
289
test/test.storagemanager.ts
Normal file
289
test/test.storagemanager.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test data
|
||||
const testData = {
|
||||
string: 'Hello, World!',
|
||||
json: { name: 'test', value: 42, nested: { data: true } },
|
||||
largeString: 'x'.repeat(10000)
|
||||
};
|
||||
|
||||
tap.test('Storage Manager - Memory Backend', async () => {
|
||||
// Create StorageManager without config (defaults to memory)
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Test basic get/set
|
||||
await storage.set('/test/key', testData.string);
|
||||
const value = await storage.get('/test/key');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Test JSON helpers
|
||||
await storage.setJSON('/test/json', testData.json);
|
||||
const jsonValue = await storage.getJSON('/test/json');
|
||||
expect(jsonValue).toEqual(testData.json);
|
||||
|
||||
// Test exists
|
||||
expect(await storage.exists('/test/key')).toEqual(true);
|
||||
expect(await storage.exists('/nonexistent')).toEqual(false);
|
||||
|
||||
// Test delete
|
||||
await storage.delete('/test/key');
|
||||
expect(await storage.exists('/test/key')).toEqual(false);
|
||||
|
||||
// Test list
|
||||
await storage.set('/items/1', 'one');
|
||||
await storage.set('/items/2', 'two');
|
||||
await storage.set('/other/3', 'three');
|
||||
|
||||
const items = await storage.list('/items');
|
||||
expect(items.length).toEqual(2);
|
||||
expect(items).toContain('/items/1');
|
||||
expect(items).toContain('/items/2');
|
||||
|
||||
// Verify memory backend
|
||||
expect(storage.getBackend()).toEqual('memory');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Filesystem Backend', async () => {
|
||||
const testDir = path.join(paths.dataDir, '.test-storage');
|
||||
|
||||
// Clean up test directory if it exists
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
// Create StorageManager with filesystem path
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Test basic operations
|
||||
await storage.set('/test/file', testData.string);
|
||||
const value = await storage.get('/test/file');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Verify file exists on disk
|
||||
const filePath = path.join(testDir, 'test', 'file');
|
||||
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
||||
expect(fileExists).toEqual(true);
|
||||
|
||||
// Test atomic writes (temp file should not exist)
|
||||
const tempPath = filePath + '.tmp';
|
||||
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
||||
expect(tempExists).toEqual(false);
|
||||
|
||||
// Test nested paths
|
||||
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
||||
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
||||
expect(nestedValue).toEqual(testData.largeString);
|
||||
|
||||
// Test list with filesystem
|
||||
await storage.set('/fs/items/a', 'alpha');
|
||||
await storage.set('/fs/items/b', 'beta');
|
||||
await storage.set('/fs/other/c', 'gamma');
|
||||
|
||||
// Filesystem backend now properly supports list
|
||||
const fsItems = await storage.list('/fs/items');
|
||||
expect(fsItems.length).toEqual(2); // Should find both items
|
||||
|
||||
// Clean up
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Custom Function Backend', async () => {
|
||||
// Create in-memory storage for custom functions
|
||||
const customStore = new Map<string, string>();
|
||||
|
||||
const storage = new StorageManager({
|
||||
readFunction: async (key: string) => {
|
||||
return customStore.get(key) || null;
|
||||
},
|
||||
writeFunction: async (key: string, value: string) => {
|
||||
customStore.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Test basic operations
|
||||
await storage.set('/custom/key', testData.string);
|
||||
expect(customStore.has('/custom/key')).toEqual(true);
|
||||
|
||||
const value = await storage.get('/custom/key');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Test that delete sets empty value (as per implementation)
|
||||
await storage.delete('/custom/key');
|
||||
expect(customStore.get('/custom/key')).toEqual('');
|
||||
|
||||
// Verify custom backend (filesystem is implemented as custom backend internally)
|
||||
expect(storage.getBackend()).toEqual('custom');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Key Validation', async () => {
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Test key normalization
|
||||
await storage.set('test/key', 'value1'); // Missing leading slash
|
||||
const value1 = await storage.get('/test/key');
|
||||
expect(value1).toEqual('value1');
|
||||
|
||||
// Test dangerous path elements are removed
|
||||
await storage.set('/test/../danger/key', 'value2');
|
||||
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
||||
expect(value2).toEqual('value2');
|
||||
|
||||
// Test multiple slashes are normalized
|
||||
await storage.set('/test///multiple////slashes', 'value3');
|
||||
const value3 = await storage.get('/test/multiple/slashes');
|
||||
expect(value3).toEqual('value3');
|
||||
|
||||
// Test invalid keys throw errors
|
||||
let emptyKeyError: Error | null = null;
|
||||
try {
|
||||
await storage.set('', 'value');
|
||||
} catch (error) {
|
||||
emptyKeyError = error as Error;
|
||||
}
|
||||
expect(emptyKeyError).toBeTruthy();
|
||||
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||
|
||||
let nullKeyError: Error | null = null;
|
||||
try {
|
||||
await storage.set(null as any, 'value');
|
||||
} catch (error) {
|
||||
nullKeyError = error as Error;
|
||||
}
|
||||
expect(nullKeyError).toBeTruthy();
|
||||
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Concurrent Access', async () => {
|
||||
const storage = new StorageManager();
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
// Simulate concurrent writes
|
||||
for (let i = 0; i < 100; i++) {
|
||||
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Verify all writes succeeded
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = await storage.get(`/concurrent/key${i}`);
|
||||
expect(value).toEqual(`value${i}`);
|
||||
}
|
||||
|
||||
// Test concurrent reads
|
||||
const readPromises: Promise<string | null>[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
readPromises.push(storage.get(`/concurrent/key${i}`));
|
||||
}
|
||||
|
||||
const results = await Promise.all(readPromises);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(results[i]).toEqual(`value${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Backend Priority', async () => {
|
||||
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
||||
|
||||
// Test that custom functions take priority over fsPath
|
||||
let warningLogged = false;
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (message: string) => {
|
||||
if (message.includes('Using custom read/write functions')) {
|
||||
warningLogged = true;
|
||||
}
|
||||
};
|
||||
|
||||
const storage = new StorageManager({
|
||||
fsPath: testDir,
|
||||
readFunction: async () => 'custom-value',
|
||||
writeFunction: async () => {}
|
||||
});
|
||||
|
||||
console.warn = originalWarn;
|
||||
|
||||
expect(warningLogged).toEqual(true);
|
||||
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Error Handling', async () => {
|
||||
// Test filesystem errors
|
||||
const storage = new StorageManager({
|
||||
readFunction: async () => {
|
||||
throw new Error('Read error');
|
||||
},
|
||||
writeFunction: async () => {
|
||||
throw new Error('Write error');
|
||||
}
|
||||
});
|
||||
|
||||
// Read errors should return null
|
||||
const value = await storage.get('/error/key');
|
||||
expect(value).toEqual(null);
|
||||
|
||||
// Write errors should propagate
|
||||
let writeError: Error | null = null;
|
||||
try {
|
||||
await storage.set('/error/key', 'value');
|
||||
} catch (error) {
|
||||
writeError = error as Error;
|
||||
}
|
||||
expect(writeError).toBeTruthy();
|
||||
expect(writeError?.message).toEqual('Write error');
|
||||
|
||||
// Test JSON parse errors
|
||||
const jsonStorage = new StorageManager({
|
||||
readFunction: async () => 'invalid json',
|
||||
writeFunction: async () => {}
|
||||
});
|
||||
|
||||
// Test JSON parse errors
|
||||
let jsonError: Error | null = null;
|
||||
try {
|
||||
await jsonStorage.getJSON('/invalid/json');
|
||||
} catch (error) {
|
||||
jsonError = error as Error;
|
||||
}
|
||||
expect(jsonError).toBeTruthy();
|
||||
expect(jsonError?.message).toContain('JSON');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - List Operations', async () => {
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Populate storage with hierarchical data
|
||||
await storage.set('/app/config/database', 'db-config');
|
||||
await storage.set('/app/config/cache', 'cache-config');
|
||||
await storage.set('/app/data/users/1', 'user1');
|
||||
await storage.set('/app/data/users/2', 'user2');
|
||||
await storage.set('/app/logs/error.log', 'errors');
|
||||
|
||||
// List root
|
||||
const rootItems = await storage.list('/');
|
||||
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// List specific paths
|
||||
const configItems = await storage.list('/app/config');
|
||||
expect(configItems.length).toEqual(2);
|
||||
expect(configItems).toContain('/app/config/database');
|
||||
expect(configItems).toContain('/app/config/cache');
|
||||
|
||||
const userItems = await storage.list('/app/data/users');
|
||||
expect(userItems.length).toEqual(2);
|
||||
|
||||
// List non-existent path
|
||||
const emptyList = await storage.list('/nonexistent/path');
|
||||
expect(emptyList.length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,9 +0,0 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
|
||||
tap.test('should create a platform service', async () => {});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
35
test_watch/devserver.ts
Normal file
35
test_watch/devserver.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Configure services as needed for development
|
||||
// OpsServer always starts on port 3000
|
||||
|
||||
// Example: Add SmartProxy routes
|
||||
// smartProxyConfig: {
|
||||
// routes: [...]
|
||||
// },
|
||||
|
||||
// Example: Add email configuration
|
||||
// emailConfig: {
|
||||
// ports: [2525],
|
||||
// hostname: 'localhost',
|
||||
// domains: [],
|
||||
// routes: []
|
||||
// },
|
||||
});
|
||||
|
||||
console.log('Starting DcRouter in development mode...');
|
||||
|
||||
await devRouter.start();
|
||||
|
||||
// Graceful shutdown handlers
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down...');
|
||||
await devRouter.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
console.log('DcRouter dev server running. Press Ctrl+C to stop.');
|
||||
@@ -2,7 +2,7 @@
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/platformservice',
|
||||
version: '2.4.2',
|
||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '5.0.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export class AIBridge {
|
||||
|
||||
}
|
||||
166
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
166
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
@@ -0,0 +1,166 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { CacheDb } from './classes.cachedb.js';
|
||||
|
||||
// Import document classes for cleanup
|
||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||
import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js';
|
||||
|
||||
/**
|
||||
* Configuration for the cache cleaner
|
||||
*/
|
||||
export interface ICacheCleanerOptions {
|
||||
/** Cleanup interval in milliseconds (default: 1 hour) */
|
||||
intervalMs?: number;
|
||||
/** Enable verbose logging */
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheCleaner - Periodically removes expired documents from the cache
|
||||
*
|
||||
* Runs on a configurable interval (default: hourly) and queries each
|
||||
* collection for documents where expiresAt < now(), then deletes them.
|
||||
*/
|
||||
export class CacheCleaner {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private options: Required<ICacheCleanerOptions>;
|
||||
private cacheDb: CacheDb;
|
||||
|
||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
||||
this.cacheDb = cacheDb;
|
||||
this.options = {
|
||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||
verbose: options.verbose || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic cleanup process
|
||||
*/
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
logger.log('warn', 'CacheCleaner already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// Run cleanup immediately on start
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||
});
|
||||
|
||||
// Schedule periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the periodic cleanup process
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
logger.log('info', 'CacheCleaner stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single cleanup cycle
|
||||
*/
|
||||
public async runCleanup(): Promise<void> {
|
||||
if (!this.cacheDb.isReady()) {
|
||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const results: { collection: string; deleted: number }[] = [];
|
||||
|
||||
try {
|
||||
const emailsDeleted = await this.cleanExpiredDocuments(CachedEmail, now);
|
||||
results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
|
||||
|
||||
const ipReputationDeleted = await this.cleanExpiredDocuments(CachedIPReputation, now);
|
||||
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
|
||||
|
||||
// Log results
|
||||
const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
|
||||
if (totalDeleted > 0 || this.options.verbose) {
|
||||
const summary = results
|
||||
.filter((r) => r.deleted > 0)
|
||||
.map((r) => `${r.collection}: ${r.deleted}`)
|
||||
.join(', ');
|
||||
logger.log(
|
||||
'info',
|
||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired documents from a specific collection using smartdata API
|
||||
*/
|
||||
private async cleanExpiredDocuments<T extends { delete: () => Promise<void> }>(
|
||||
documentClass: { getInstances: (filter: any) => Promise<T[]> },
|
||||
now: Date
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Find all expired documents
|
||||
const expiredDocs = await documentClass.getInstances({
|
||||
expiresAt: { $lt: now },
|
||||
});
|
||||
|
||||
// Delete each expired document
|
||||
let deletedCount = 0;
|
||||
for (const doc of expiredDocs) {
|
||||
try {
|
||||
await doc.delete();
|
||||
deletedCount++;
|
||||
} catch (deleteError) {
|
||||
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cleaner is running
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cleanup interval in milliseconds
|
||||
*/
|
||||
public getIntervalMs(): number {
|
||||
return this.options.intervalMs;
|
||||
}
|
||||
}
|
||||
111
ts/cache/classes.cached.document.ts
vendored
Normal file
111
ts/cache/classes.cached.document.ts
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Base class for all cached documents with TTL support
|
||||
*
|
||||
* Extends smartdata's SmartDataDbDoc to add:
|
||||
* - Automatic timestamps (createdAt, lastAccessedAt)
|
||||
* - TTL/expiration support (expiresAt)
|
||||
* - Helper methods for TTL management
|
||||
*
|
||||
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
|
||||
* since decorators on abstract classes don't propagate correctly.
|
||||
*/
|
||||
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
|
||||
/**
|
||||
* Timestamp when the document was created
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Timestamp when the document expires and should be cleaned up
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public expiresAt: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last access (for LRU-style eviction if needed)
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Set the TTL (time to live) for this document
|
||||
* @param ttlMs Time to live in milliseconds
|
||||
*/
|
||||
public setTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using days
|
||||
* @param days Number of days until expiration
|
||||
*/
|
||||
public setTTLDays(days: number): void {
|
||||
this.setTTL(days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using hours
|
||||
* @param hours Number of hours until expiration
|
||||
*/
|
||||
public setTTLHours(hours: number): void {
|
||||
this.setTTL(hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this document has expired
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
if (!this.expiresAt) {
|
||||
return false; // No expiration set
|
||||
}
|
||||
return new Date() > this.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the lastAccessedAt timestamp
|
||||
*/
|
||||
public touch(): void {
|
||||
this.lastAccessedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining TTL in milliseconds
|
||||
* Returns 0 if expired, -1 if no expiration set
|
||||
*/
|
||||
public getRemainingTTL(): number {
|
||||
if (!this.expiresAt) {
|
||||
return -1;
|
||||
}
|
||||
const remaining = this.expiresAt.getTime() - Date.now();
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the TTL by the specified milliseconds from now
|
||||
* @param ttlMs Additional time to live in milliseconds
|
||||
*/
|
||||
public extendTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the document to never expire (100 years in the future)
|
||||
*/
|
||||
public setNeverExpires(): void {
|
||||
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL constants in milliseconds
|
||||
*/
|
||||
export const TTL = {
|
||||
HOURS_1: 1 * 60 * 60 * 1000,
|
||||
HOURS_24: 24 * 60 * 60 * 1000,
|
||||
DAYS_7: 7 * 24 * 60 * 60 * 1000,
|
||||
DAYS_30: 30 * 24 * 60 * 60 * 1000,
|
||||
DAYS_90: 90 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
154
ts/cache/classes.cachedb.ts
vendored
Normal file
154
ts/cache/classes.cachedb.ts
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Configuration options for CacheDb
|
||||
*/
|
||||
export interface ICacheDbOptions {
|
||||
/** Base storage path for TsmDB data (default: /etc/dcrouter/tsmdb) */
|
||||
storagePath?: string;
|
||||
/** Database name (default: dcrouter) */
|
||||
dbName?: string;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
||||
*
|
||||
* Provides persistent caching using smartdata as the ORM layer
|
||||
* and LocalTsmDb as the embedded database engine.
|
||||
*/
|
||||
export class CacheDb {
|
||||
private static instance: CacheDb | null = null;
|
||||
|
||||
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
||||
private smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
private options: Required<ICacheDbOptions>;
|
||||
private isStarted: boolean = false;
|
||||
|
||||
constructor(options: ICacheDbOptions = {}) {
|
||||
this.options = {
|
||||
storagePath: options.storagePath || '/etc/dcrouter/tsmdb',
|
||||
dbName: options.dbName || 'dcrouter',
|
||||
debug: options.debug || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton instance
|
||||
*/
|
||||
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
||||
if (!CacheDb.instance) {
|
||||
CacheDb.instance = new CacheDb(options);
|
||||
}
|
||||
return CacheDb.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
CacheDb.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cache database
|
||||
* - Initializes LocalTsmDb with file persistence
|
||||
* - Connects smartdata to the LocalTsmDb via Unix socket
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.log('warn', 'CacheDb already started');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||
|
||||
// Create LocalTsmDb instance
|
||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||
folderPath: this.options.storagePath,
|
||||
});
|
||||
|
||||
// Start LocalTsmDb and get connection info
|
||||
const connectionInfo = await this.localTsmDb.start();
|
||||
|
||||
if (this.options.debug) {
|
||||
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
|
||||
}
|
||||
|
||||
// Initialize smartdata with the connection URI
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUrl: connectionInfo.connectionUri,
|
||||
mongoDbName: this.options.dbName,
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
|
||||
this.isStarted = true;
|
||||
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cache database
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Close smartdata connection
|
||||
if (this.smartdataDb) {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
|
||||
// Stop LocalTsmDb
|
||||
if (this.localTsmDb) {
|
||||
await this.localTsmDb.stop();
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
logger.log('info', 'CacheDb stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smartdata database instance
|
||||
*/
|
||||
public getDb(): plugins.smartdata.SmartdataDb {
|
||||
if (!this.isStarted) {
|
||||
throw new Error('CacheDb not started. Call start() first.');
|
||||
}
|
||||
return this.smartdataDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is ready
|
||||
*/
|
||||
public isReady(): boolean {
|
||||
return this.isStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path
|
||||
*/
|
||||
public getStoragePath(): string {
|
||||
return this.options.storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database name
|
||||
*/
|
||||
public getDbName(): string {
|
||||
return this.options.dbName;
|
||||
}
|
||||
}
|
||||
240
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
240
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Email status in the cache
|
||||
*/
|
||||
export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* CachedEmail - Stores email queue items in the cache
|
||||
*
|
||||
* Used for persistent email queue storage, tracking delivery status,
|
||||
* and maintaining email history for the configured TTL period.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
// TTL fields from base class (decorators required on concrete class)
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Unique identifier for this email
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
|
||||
/**
|
||||
* Email message ID (RFC 822 Message-ID header)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public messageId: string;
|
||||
|
||||
/**
|
||||
* Sender email address (envelope from)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public from: string;
|
||||
|
||||
/**
|
||||
* Recipient email addresses
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public to: string[];
|
||||
|
||||
/**
|
||||
* CC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public cc: string[];
|
||||
|
||||
/**
|
||||
* BCC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bcc: string[];
|
||||
|
||||
/**
|
||||
* Email subject
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public subject: string;
|
||||
|
||||
/**
|
||||
* Raw RFC822 email content
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rawContent: string;
|
||||
|
||||
/**
|
||||
* Current status of the email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TCachedEmailStatus;
|
||||
|
||||
/**
|
||||
* Number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public attempts: number = 0;
|
||||
|
||||
/**
|
||||
* Maximum number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public maxAttempts: number = 3;
|
||||
|
||||
/**
|
||||
* Timestamp for next delivery attempt
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public nextAttempt: Date;
|
||||
|
||||
/**
|
||||
* Last error message if delivery failed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the email was successfully delivered
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public deliveredAt: Date;
|
||||
|
||||
/**
|
||||
* Sender domain (for querying/filtering)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public senderDomain: string;
|
||||
|
||||
/**
|
||||
* Priority level (higher = more important)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public priority: number = 0;
|
||||
|
||||
/**
|
||||
* JSON-serialized route data
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public routeData: string;
|
||||
|
||||
/**
|
||||
* DKIM signature status
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public dkimSigned: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||
this.status = 'pending';
|
||||
this.to = [];
|
||||
this.cc = [];
|
||||
this.bcc = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new CachedEmail with a unique ID
|
||||
*/
|
||||
public static createNew(): CachedEmail {
|
||||
const email = new CachedEmail();
|
||||
email.id = plugins.uuid.v4();
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an email by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<CachedEmail | null> {
|
||||
return await CachedEmail.getInstance({
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails with a specific status
|
||||
*/
|
||||
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails pending delivery (status = pending and nextAttempt <= now)
|
||||
*/
|
||||
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
|
||||
const now = new Date();
|
||||
return await CachedEmail.getInstances({
|
||||
status: 'pending',
|
||||
nextAttempt: { $lte: now },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find emails by sender domain
|
||||
*/
|
||||
public static async findBySenderDomain(domain: string): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
senderDomain: domain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as delivered
|
||||
*/
|
||||
public markDelivered(): void {
|
||||
this.status = 'delivered';
|
||||
this.deliveredAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as failed with error
|
||||
*/
|
||||
public markFailed(error: string): void {
|
||||
this.status = 'failed';
|
||||
this.lastError = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment attempt counter and schedule next attempt
|
||||
*/
|
||||
public scheduleRetry(delayMs: number = 5 * 60 * 1000): void {
|
||||
this.attempts++;
|
||||
this.status = 'deferred';
|
||||
this.nextAttempt = new Date(Date.now() + delayMs);
|
||||
|
||||
// If max attempts reached, mark as failed
|
||||
if (this.attempts >= this.maxAttempts) {
|
||||
this.status = 'failed';
|
||||
this.lastError = `Max attempts (${this.maxAttempts}) reached`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sender domain from email address
|
||||
*/
|
||||
public updateSenderDomain(): void {
|
||||
if (this.from) {
|
||||
const match = this.from.match(/@([^>]+)>?$/);
|
||||
if (match) {
|
||||
this.senderDomain = match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
247
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
247
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
@@ -0,0 +1,247 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* IP reputation result data
|
||||
*/
|
||||
export interface IIPReputationData {
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
isProxy: boolean;
|
||||
isTor: boolean;
|
||||
isVPN: boolean;
|
||||
country?: string;
|
||||
asn?: string;
|
||||
org?: string;
|
||||
blacklists?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CachedIPReputation - Stores IP reputation lookup results
|
||||
*
|
||||
* Caches the results of IP reputation checks to avoid repeated
|
||||
* external API calls. Default TTL is 24 hours.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
// TTL fields from base class (decorators required on concrete class)
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* IP address (unique identifier)
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress: string;
|
||||
|
||||
/**
|
||||
* Reputation score (0-100, higher = better)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public score: number;
|
||||
|
||||
/**
|
||||
* Whether the IP is flagged as spam source
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isSpam: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a known proxy
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isProxy: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a Tor exit node
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isTor: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a VPN endpoint
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isVPN: boolean;
|
||||
|
||||
/**
|
||||
* Country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string;
|
||||
|
||||
/**
|
||||
* Autonomous System Number
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: string;
|
||||
|
||||
/**
|
||||
* Organization name
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public org: string;
|
||||
|
||||
/**
|
||||
* List of blacklists the IP appears on
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blacklists: string[];
|
||||
|
||||
/**
|
||||
* Number of times this IP has been checked
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public checkCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of connections from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public connectionCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of emails received from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public emailCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of spam emails from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public spamCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.HOURS_24); // Default 24-hour TTL
|
||||
this.blacklists = [];
|
||||
this.score = 50; // Default neutral score
|
||||
this.isSpam = false;
|
||||
this.isProxy = false;
|
||||
this.isTor = false;
|
||||
this.isVPN = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from reputation data
|
||||
*/
|
||||
public static fromReputationData(ipAddress: string, data: IIPReputationData): CachedIPReputation {
|
||||
const cached = new CachedIPReputation();
|
||||
cached.ipAddress = ipAddress;
|
||||
cached.score = data.score;
|
||||
cached.isSpam = data.isSpam;
|
||||
cached.isProxy = data.isProxy;
|
||||
cached.isTor = data.isTor;
|
||||
cached.isVPN = data.isVPN;
|
||||
cached.country = data.country || '';
|
||||
cached.asn = data.asn || '';
|
||||
cached.org = data.org || '';
|
||||
cached.blacklists = data.blacklists || [];
|
||||
cached.checkCount = 1;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to reputation data object
|
||||
*/
|
||||
public toReputationData(): IIPReputationData {
|
||||
this.touch();
|
||||
return {
|
||||
score: this.score,
|
||||
isSpam: this.isSpam,
|
||||
isProxy: this.isProxy,
|
||||
isTor: this.isTor,
|
||||
isVPN: this.isVPN,
|
||||
country: this.country,
|
||||
asn: this.asn,
|
||||
org: this.org,
|
||||
blacklists: this.blacklists,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by IP address
|
||||
*/
|
||||
public static async findByIP(ipAddress: string): Promise<CachedIPReputation | null> {
|
||||
return await CachedIPReputation.getInstance({
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all IPs flagged as spam
|
||||
*/
|
||||
public static async findSpamIPs(): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
isSpam: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find IPs with score below threshold
|
||||
*/
|
||||
public static async findLowScoreIPs(threshold: number): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
score: { $lt: threshold },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a connection from this IP
|
||||
*/
|
||||
public recordConnection(): void {
|
||||
this.connectionCount++;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an email from this IP
|
||||
*/
|
||||
public recordEmail(isSpam: boolean = false): void {
|
||||
this.emailCount++;
|
||||
if (isSpam) {
|
||||
this.spamCount++;
|
||||
}
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the reputation data
|
||||
*/
|
||||
public updateReputation(data: IIPReputationData): void {
|
||||
this.score = data.score;
|
||||
this.isSpam = data.isSpam;
|
||||
this.isProxy = data.isProxy;
|
||||
this.isTor = data.isTor;
|
||||
this.isVPN = data.isVPN;
|
||||
this.country = data.country || this.country;
|
||||
this.asn = data.asn || this.asn;
|
||||
this.org = data.org || this.org;
|
||||
this.blacklists = data.blacklists || this.blacklists;
|
||||
this.checkCount++;
|
||||
this.touch();
|
||||
// Refresh TTL on update
|
||||
this.setTTL(TTL.HOURS_24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this IP should be blocked
|
||||
*/
|
||||
public shouldBlock(): boolean {
|
||||
return this.isSpam || this.score < 20 || this.blacklists.length > 2;
|
||||
}
|
||||
}
|
||||
2
ts/cache/documents/index.ts
vendored
Normal file
2
ts/cache/documents/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
7
ts/cache/index.ts
vendored
Normal file
7
ts/cache/index.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Core cache infrastructure
|
||||
export * from './classes.cachedb.js';
|
||||
export * from './classes.cached.document.js';
|
||||
export * from './classes.cache.cleaner.js';
|
||||
|
||||
// Document classes
|
||||
export * from './documents/index.js';
|
||||
1377
ts/classes.dcrouter.ts
Normal file
1377
ts/classes.dcrouter.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { SzPlatformService } from './platformservice.js';
|
||||
|
||||
|
||||
|
||||
export class PlatformServiceDb {
|
||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
public platformserviceRef: SzPlatformService;
|
||||
|
||||
constructor(platformserviceRefArg: SzPlatformService) {
|
||||
this.platformserviceRef = platformserviceRefArg;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUser: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
|
||||
mongoDbName: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
|
||||
mongoDbPass: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
|
||||
mongoDbUrl: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
}
|
||||
2
ts/config/index.ts
Normal file
2
ts/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Export validation tools only
|
||||
export * from './validator.js';
|
||||
266
ts/config/validator.ts
Normal file
266
ts/config/validator.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { ValidationError } from '../errors/base.errors.js';
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface IValidationResult {
|
||||
/**
|
||||
* Whether the validation passed
|
||||
*/
|
||||
valid: boolean;
|
||||
|
||||
/**
|
||||
* Validation errors if any
|
||||
*/
|
||||
errors?: string[];
|
||||
|
||||
/**
|
||||
* Validated configuration (may include defaults)
|
||||
*/
|
||||
config?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation schema types
|
||||
*/
|
||||
export type ValidationSchema = Record<string, {
|
||||
/**
|
||||
* Type of the value
|
||||
*/
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
|
||||
/**
|
||||
* Whether the field is required
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* Default value if not specified
|
||||
*/
|
||||
default?: any;
|
||||
|
||||
/**
|
||||
* Minimum value (for numbers)
|
||||
*/
|
||||
min?: number;
|
||||
|
||||
/**
|
||||
* Maximum value (for numbers)
|
||||
*/
|
||||
max?: number;
|
||||
|
||||
/**
|
||||
* Minimum length (for strings or arrays)
|
||||
*/
|
||||
minLength?: number;
|
||||
|
||||
/**
|
||||
* Maximum length (for strings or arrays)
|
||||
*/
|
||||
maxLength?: number;
|
||||
|
||||
/**
|
||||
* Pattern to match (for strings)
|
||||
*/
|
||||
pattern?: RegExp;
|
||||
|
||||
/**
|
||||
* Allowed values (for strings, numbers)
|
||||
*/
|
||||
enum?: any[];
|
||||
|
||||
/**
|
||||
* Nested schema (for objects)
|
||||
*/
|
||||
schema?: ValidationSchema;
|
||||
|
||||
/**
|
||||
* Item schema (for arrays)
|
||||
*/
|
||||
items?: {
|
||||
type: 'string' | 'number' | 'boolean' | 'object';
|
||||
schema?: ValidationSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom validation function
|
||||
*/
|
||||
validate?: (value: any) => boolean | string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Configuration validator
|
||||
* Validates configuration objects against schemas and provides default values
|
||||
*/
|
||||
export class ConfigValidator {
|
||||
|
||||
/**
|
||||
* Validate a configuration object against a schema
|
||||
*
|
||||
* @param config Configuration object to validate
|
||||
* @param schema Validation schema
|
||||
* @returns Validation result
|
||||
*/
|
||||
public static validate<T>(config: T, schema: ValidationSchema): IValidationResult {
|
||||
const errors: string[] = [];
|
||||
const validatedConfig = { ...config };
|
||||
|
||||
// Validate each field against the schema
|
||||
for (const [key, rules] of Object.entries(schema)) {
|
||||
const value = config[key];
|
||||
|
||||
// Check if required
|
||||
if (rules.required && (value === undefined || value === null)) {
|
||||
errors.push(`${key} is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If not present and not required, apply default if available
|
||||
if ((value === undefined || value === null)) {
|
||||
if (rules.default !== undefined) {
|
||||
validatedConfig[key] = rules.default;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
if (value !== undefined && value !== null) {
|
||||
const valueType = Array.isArray(value) ? 'array' : typeof value;
|
||||
if (valueType !== rules.type) {
|
||||
errors.push(`${key} must be of type ${rules.type}, got ${valueType}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type-specific validations
|
||||
switch (rules.type) {
|
||||
case 'number':
|
||||
if (rules.min !== undefined && value < rules.min) {
|
||||
errors.push(`${key} must be at least ${rules.min}`);
|
||||
}
|
||||
if (rules.max !== undefined && value > rules.max) {
|
||||
errors.push(`${key} must be at most ${rules.max}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'string':
|
||||
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||
errors.push(`${key} must be at least ${rules.minLength} characters`);
|
||||
}
|
||||
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||
errors.push(`${key} must be at most ${rules.maxLength} characters`);
|
||||
}
|
||||
if (rules.pattern && !rules.pattern.test(value)) {
|
||||
errors.push(`${key} must match pattern ${rules.pattern}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||
errors.push(`${key} must have at least ${rules.minLength} items`);
|
||||
}
|
||||
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||
errors.push(`${key} must have at most ${rules.maxLength} items`);
|
||||
}
|
||||
if (rules.items && value.length > 0) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const itemType = Array.isArray(value[i]) ? 'array' : typeof value[i];
|
||||
if (itemType !== rules.items.type) {
|
||||
errors.push(`${key}[${i}] must be of type ${rules.items.type}, got ${itemType}`);
|
||||
} else if (rules.items.schema && itemType === 'object') {
|
||||
const itemResult = this.validate(value[i], rules.items.schema);
|
||||
if (!itemResult.valid) {
|
||||
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
if (rules.schema) {
|
||||
const nestedResult = this.validate(value, rules.schema);
|
||||
if (!nestedResult.valid) {
|
||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
||||
}
|
||||
validatedConfig[key] = nestedResult.config;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Enum validation
|
||||
if (rules.enum && !rules.enum.includes(value)) {
|
||||
errors.push(`${key} must be one of [${rules.enum.join(', ')}]`);
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (rules.validate) {
|
||||
const result = rules.validate(value);
|
||||
if (result !== true) {
|
||||
errors.push(typeof result === 'string' ? result : `${key} failed custom validation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
config: validatedConfig
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Apply defaults to a configuration object based on a schema
|
||||
*
|
||||
* @param config Configuration object to apply defaults to
|
||||
* @param schema Validation schema with defaults
|
||||
* @returns Configuration with defaults applied
|
||||
*/
|
||||
public static applyDefaults<T>(config: T, schema: ValidationSchema): T {
|
||||
const result = { ...config };
|
||||
|
||||
for (const [key, rules] of Object.entries(schema)) {
|
||||
if (result[key] === undefined && rules.default !== undefined) {
|
||||
result[key] = rules.default;
|
||||
}
|
||||
|
||||
// Apply defaults to nested objects
|
||||
if (result[key] && rules.type === 'object' && rules.schema) {
|
||||
result[key] = this.applyDefaults(result[key], rules.schema);
|
||||
}
|
||||
|
||||
// Apply defaults to array items
|
||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||
result[key] = result[key].map(item =>
|
||||
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a validation error if the configuration is invalid
|
||||
*
|
||||
* @param config Configuration to validate
|
||||
* @param schema Validation schema
|
||||
* @returns Validated configuration with defaults
|
||||
* @throws ValidationError if validation fails
|
||||
*/
|
||||
public static validateOrThrow<T>(config: T, schema: ValidationSchema): T {
|
||||
const result = this.validate(config, schema);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new ValidationError(
|
||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
||||
'CONFIG_VALIDATION_ERROR',
|
||||
{ data: { errors: result.errors } }
|
||||
);
|
||||
}
|
||||
|
||||
return result.config;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type DcRouter from './classes.dcrouter.js';
|
||||
|
||||
export class SzDcRouterConnector {
|
||||
public qenv: plugins.qenv.Qenv;
|
||||
public dcRouterRef: DcRouter;
|
||||
constructor(dcRouterRef: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRef;
|
||||
this.dcRouterRef.options.platformServiceInstance?.serviceQenv || new plugins.qenv.Qenv('./', '.nogit/');
|
||||
}
|
||||
|
||||
public async getEnvVarOnDemand(varName: string): Promise<string> {
|
||||
return this.qenv.getEnvVarOnDemand(varName) || '';
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { SzDcRouterConnector } from './classes.dcr.sz.connector.js';
|
||||
|
||||
import type { SzPlatformService } from '../platformservice.js';
|
||||
import { type IMtaConfig, MtaService } from '../mta/classes.mta.js';
|
||||
|
||||
// Certificate types are available via plugins.tsclass
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
platformServiceInstance?: SzPlatformService;
|
||||
|
||||
/** SmartProxy (TCP/SNI) configuration */
|
||||
smartProxyOptions?: plugins.smartproxy.ISmartProxyOptions;
|
||||
/** Reverse proxy host configurations for HTTP(S) layer */
|
||||
reverseProxyConfigs?: plugins.smartproxy.IReverseProxyConfig[];
|
||||
/** MTA (SMTP) service configuration */
|
||||
mtaConfig?: IMtaConfig;
|
||||
/** Existing MTA service instance to use instead of creating a new one */
|
||||
mtaServiceInstance?: MtaService;
|
||||
/** DNS server configuration */
|
||||
dnsServerConfig?: plugins.smartdns.IDnsServerOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* DcRouter can be run on ingress and egress to and from a datacenter site.
|
||||
*/
|
||||
/**
|
||||
* Context passed to HTTP routing rules
|
||||
*/
|
||||
/**
|
||||
* Context passed to port proxy (SmartProxy) routing rules
|
||||
*/
|
||||
export interface PortProxyRuleContext {
|
||||
proxy: plugins.smartproxy.SmartProxy;
|
||||
configs: plugins.smartproxy.IPortProxySettings['domainConfigs'];
|
||||
}
|
||||
export class DcRouter {
|
||||
public szDcRouterConnector = new SzDcRouterConnector(this);
|
||||
public options: IDcRouterOptions;
|
||||
public smartProxy?: plugins.smartproxy.SmartProxy;
|
||||
public mta?: MtaService;
|
||||
public dnsServer?: plugins.smartdns.DnsServer;
|
||||
/** SMTP rule engine */
|
||||
public smtpRuleEngine?: plugins.smartrule.SmartRule<any>;
|
||||
constructor(optionsArg: IDcRouterOptions) {
|
||||
// Set defaults in options
|
||||
this.options = {
|
||||
...optionsArg
|
||||
};
|
||||
}
|
||||
|
||||
public async start() {
|
||||
// Set up MTA service - use existing instance if provided
|
||||
if (this.options.mtaServiceInstance) {
|
||||
// Use provided MTA service instance
|
||||
this.mta = this.options.mtaServiceInstance;
|
||||
console.log('Using provided MTA service instance');
|
||||
|
||||
// Get the SMTP rule engine from the provided MTA
|
||||
this.smtpRuleEngine = this.mta.smtpRuleEngine;
|
||||
} else if (this.options.mtaConfig) {
|
||||
// Create new MTA service with the provided configuration
|
||||
this.mta = new MtaService(undefined, this.options.mtaConfig);
|
||||
console.log('Created new MTA service instance');
|
||||
|
||||
// Initialize SMTP rule engine
|
||||
this.smtpRuleEngine = this.mta.smtpRuleEngine;
|
||||
}
|
||||
|
||||
// TCP/SNI proxy (SmartProxy)
|
||||
if (this.options.smartProxyOptions) {
|
||||
// Lets setup smartacme
|
||||
let certProvisionFunction: plugins.smartproxy.ISmartProxyOptions['certProvisionFunction'];
|
||||
|
||||
// Check if we can share certificate from MTA service
|
||||
if (this.options.mtaServiceInstance && this.mta) {
|
||||
// Share TLS certificate with MTA service (if available)
|
||||
console.log('Using MTA service certificate for SmartProxy');
|
||||
|
||||
// Create proxy function to get cert from MTA service
|
||||
certProvisionFunction = async (domainArg) => {
|
||||
// Get cert from provided MTA service if available
|
||||
if (this.mta && this.mta.certificate) {
|
||||
console.log(`Using MTA certificate for domain ${domainArg}`);
|
||||
// Return in the format expected by SmartProxy
|
||||
const certExpiry = this.mta.certificate.expiresAt;
|
||||
const certObj: plugins.tsclass.network.ICert = {
|
||||
id: `cert-${domainArg}`,
|
||||
domainName: domainArg,
|
||||
privateKey: this.mta.certificate.privateKey,
|
||||
publicKey: this.mta.certificate.publicKey,
|
||||
created: Date.now(),
|
||||
validUntil: certExpiry instanceof Date ? certExpiry.getTime() : Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||
csr: ''
|
||||
};
|
||||
return certObj;
|
||||
} else {
|
||||
console.log(`No MTA certificate available for domain ${domainArg}, falling back to ACME`);
|
||||
// Return string literal instead of 'http01' enum value
|
||||
return null; // Let SmartProxy fall back to its default mechanism
|
||||
}
|
||||
};
|
||||
} else if (true) {
|
||||
// Set up ACME for certificate provisioning
|
||||
const smartAcmeInstance = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: this.options.smartProxyOptions.acme.accountEmail,
|
||||
certManager: new plugins.smartacme.certmanagers.MongoCertManager({
|
||||
mongoDbUrl: await this.szDcRouterConnector.getEnvVarOnDemand('MONGO_DB_URL'),
|
||||
mongoDbUser: await this.szDcRouterConnector.getEnvVarOnDemand('MONGO_DB_USER'),
|
||||
mongoDbPass: await this.szDcRouterConnector.getEnvVarOnDemand('MONGO_DB_PASS'),
|
||||
mongoDbName: await this.szDcRouterConnector.getEnvVarOnDemand('MONGO_DB_NAME'),
|
||||
}),
|
||||
environment: 'production',
|
||||
accountPrivateKey: await this.szDcRouterConnector.getEnvVarOnDemand('ACME_ACCOUNT_PRIVATE_KEY'),
|
||||
challengeHandlers: [
|
||||
new plugins.smartacme.handlers.Dns01Handler(new plugins.cloudflare.CloudflareAccount('')) // TODO
|
||||
],
|
||||
});
|
||||
|
||||
certProvisionFunction = async (domainArg) => {
|
||||
try {
|
||||
const domainSupported = await smartAcmeInstance.challengeHandlers[0].checkWetherDomainIsSupported(domainArg);
|
||||
if (!domainSupported) {
|
||||
return null; // Let SmartProxy handle with default mechanism
|
||||
}
|
||||
// Get the certificate and convert to ICert
|
||||
const cert = await smartAcmeInstance.getCertificateForDomain(domainArg);
|
||||
if (typeof cert === 'string') {
|
||||
return null; // String result indicates fallback
|
||||
}
|
||||
|
||||
// Return in the format expected by SmartProxy
|
||||
const result: plugins.tsclass.network.ICert = {
|
||||
id: `cert-${domainArg}`,
|
||||
domainName: domainArg,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000, // 90 days
|
||||
csr: ''
|
||||
};
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Certificate error for ${domainArg}:`, err);
|
||||
return null; // Let SmartProxy handle with default mechanism
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Create the SmartProxy instance with the appropriate cert provisioning function
|
||||
const smartProxyOptions = {
|
||||
...this.options.smartProxyOptions,
|
||||
certProvisionFunction
|
||||
};
|
||||
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyOptions);
|
||||
|
||||
// Configure SmartProxy for SMTP if we have an MTA service
|
||||
if (this.mta) {
|
||||
this.configureSmtpProxy();
|
||||
}
|
||||
}
|
||||
|
||||
// DNS server
|
||||
if (this.options.dnsServerConfig) {
|
||||
this.dnsServer = new plugins.smartdns.DnsServer(this.options.dnsServerConfig);
|
||||
}
|
||||
|
||||
// Start SmartProxy if configured
|
||||
if (this.smartProxy) {
|
||||
await this.smartProxy.start();
|
||||
}
|
||||
|
||||
// Start MTA service if configured and it's our own service (not an external instance)
|
||||
if (this.mta && !this.options.mtaServiceInstance) {
|
||||
await this.mta.start();
|
||||
}
|
||||
|
||||
// Start DNS server if configured
|
||||
if (this.dnsServer) {
|
||||
await this.dnsServer.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure SmartProxy for SMTP ports
|
||||
*/
|
||||
public configureSmtpProxy(): void {
|
||||
if (!this.smartProxy || !this.mta) return;
|
||||
|
||||
const mtaPort = this.mta.config.smtp?.port || 25;
|
||||
try {
|
||||
// Configure SmartProxy to forward SMTP ports to the MTA service
|
||||
const settings = this.smartProxy.settings;
|
||||
// Ensure localhost target for MTA
|
||||
settings.targetIP = settings.targetIP || 'localhost';
|
||||
// Forward all SMTP ports to the MTA port
|
||||
settings.toPort = mtaPort;
|
||||
// Initialize globalPortRanges if needed
|
||||
if (!settings.globalPortRanges) {
|
||||
settings.globalPortRanges = [];
|
||||
}
|
||||
// Add SMTP ports 25, 587, 465 if not already present
|
||||
for (const port of [25, 587, 465]) {
|
||||
if (!settings.globalPortRanges.some((r) => r.from <= port && port <= r.to)) {
|
||||
settings.globalPortRanges.push({ from: port, to: port });
|
||||
}
|
||||
}
|
||||
console.log(`Configured SmartProxy for SMTP ports: 25, 587, 465 → localhost:${mtaPort}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to configure SmartProxy for SMTP:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
// Stop SmartProxy
|
||||
if (this.smartProxy) {
|
||||
await this.smartProxy.stop();
|
||||
}
|
||||
|
||||
// Stop MTA service if it's our own (not an external instance)
|
||||
if (this.mta && !this.options.mtaServiceInstance) {
|
||||
await this.mta.stop();
|
||||
}
|
||||
|
||||
// Stop DNS server
|
||||
if (this.dnsServer) {
|
||||
await this.dnsServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an SMTP routing rule
|
||||
*/
|
||||
public addSmtpRule(
|
||||
priority: number,
|
||||
check: (email: any) => Promise<any>,
|
||||
action: (email: any) => Promise<any>
|
||||
): void {
|
||||
this.smtpRuleEngine?.createRule(priority, check, action);
|
||||
}
|
||||
}
|
||||
|
||||
export default DcRouter;
|
||||
@@ -1 +0,0 @@
|
||||
export * from './classes.dcrouter.js';
|
||||
@@ -1,896 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
/**
|
||||
* Represents a single stage in the warmup process
|
||||
*/
|
||||
export interface IWarmupStage {
|
||||
/** Stage number (1-based) */
|
||||
stage: number;
|
||||
/** Maximum daily email volume for this stage */
|
||||
maxDailyVolume: number;
|
||||
/** Duration of this stage in days */
|
||||
durationDays: number;
|
||||
/** Target engagement metrics for this stage */
|
||||
targetMetrics?: {
|
||||
/** Minimum open rate (percentage) */
|
||||
minOpenRate?: number;
|
||||
/** Maximum bounce rate (percentage) */
|
||||
maxBounceRate?: number;
|
||||
/** Maximum spam complaint rate (percentage) */
|
||||
maxComplaintRate?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for IP warmup process
|
||||
*/
|
||||
export interface IIPWarmupConfig {
|
||||
/** Whether the warmup is enabled */
|
||||
enabled?: boolean;
|
||||
/** List of IP addresses to warm up */
|
||||
ipAddresses?: string[];
|
||||
/** Target domains to warm up (e.g. your sending domains) */
|
||||
targetDomains?: string[];
|
||||
/** Warmup stages defining volume and duration */
|
||||
stages?: IWarmupStage[];
|
||||
/** Date when warmup process started */
|
||||
startDate?: Date;
|
||||
/** Default hourly distribution for sending (percentage of daily volume per hour) */
|
||||
hourlyDistribution?: number[];
|
||||
/** Whether to automatically advance stages based on metrics */
|
||||
autoAdvanceStages?: boolean;
|
||||
/** Whether to suspend warmup if metrics decline */
|
||||
suspendOnMetricDecline?: boolean;
|
||||
/** Percentage of traffic to send through fallback provider during warmup */
|
||||
fallbackPercentage?: number;
|
||||
/** Whether to prioritize engaged subscribers during warmup */
|
||||
prioritizeEngagedSubscribers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status for a specific IP's warmup process
|
||||
*/
|
||||
export interface IIPWarmupStatus {
|
||||
/** IP address being warmed up */
|
||||
ipAddress: string;
|
||||
/** Current warmup stage */
|
||||
currentStage: number;
|
||||
/** Start date of the warmup process */
|
||||
startDate: Date;
|
||||
/** Start date of the current stage */
|
||||
currentStageStartDate: Date;
|
||||
/** Target completion date for entire warmup */
|
||||
targetCompletionDate: Date;
|
||||
/** Daily volume allocation for current stage */
|
||||
currentDailyAllocation: number;
|
||||
/** Emails sent in current stage */
|
||||
sentInCurrentStage: number;
|
||||
/** Total emails sent during warmup process */
|
||||
totalSent: number;
|
||||
/** Whether the warmup is currently active */
|
||||
isActive: boolean;
|
||||
/** Daily statistics for the past week */
|
||||
dailyStats: Array<{
|
||||
/** Date of the statistics */
|
||||
date: string;
|
||||
/** Number of emails sent */
|
||||
sent: number;
|
||||
/** Number of emails opened */
|
||||
opened: number;
|
||||
/** Number of bounces */
|
||||
bounces: number;
|
||||
/** Number of spam complaints */
|
||||
complaints: number;
|
||||
}>;
|
||||
/** Current metrics */
|
||||
metrics: {
|
||||
/** Open rate percentage */
|
||||
openRate: number;
|
||||
/** Bounce rate percentage */
|
||||
bounceRate: number;
|
||||
/** Complaint rate percentage */
|
||||
complaintRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines methods for a policy used to allocate emails to different IPs
|
||||
*/
|
||||
export interface IIPAllocationPolicy {
|
||||
/** Name of the policy */
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Allocate an IP address for sending an email
|
||||
* @param availableIPs List of available IP addresses
|
||||
* @param emailInfo Information about the email being sent
|
||||
* @returns The IP to use, or null if no IP is available
|
||||
*/
|
||||
allocateIP(
|
||||
availableIPs: Array<{ ip: string; priority: number; capacity: number }>,
|
||||
emailInfo: {
|
||||
from: string;
|
||||
to: string[];
|
||||
domain: string;
|
||||
isTransactional: boolean;
|
||||
isWarmup: boolean;
|
||||
}
|
||||
): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default IP warmup configuration with industry standard stages
|
||||
*/
|
||||
const DEFAULT_WARMUP_CONFIG: Required<IIPWarmupConfig> = {
|
||||
enabled: true,
|
||||
ipAddresses: [],
|
||||
targetDomains: [],
|
||||
stages: [
|
||||
{ stage: 1, maxDailyVolume: 50, durationDays: 2, targetMetrics: { maxBounceRate: 8, minOpenRate: 15 } },
|
||||
{ stage: 2, maxDailyVolume: 100, durationDays: 2, targetMetrics: { maxBounceRate: 7, minOpenRate: 18 } },
|
||||
{ stage: 3, maxDailyVolume: 500, durationDays: 3, targetMetrics: { maxBounceRate: 6, minOpenRate: 20 } },
|
||||
{ stage: 4, maxDailyVolume: 1000, durationDays: 3, targetMetrics: { maxBounceRate: 5, minOpenRate: 20 } },
|
||||
{ stage: 5, maxDailyVolume: 5000, durationDays: 5, targetMetrics: { maxBounceRate: 3, minOpenRate: 22 } },
|
||||
{ stage: 6, maxDailyVolume: 10000, durationDays: 5, targetMetrics: { maxBounceRate: 2, minOpenRate: 25 } },
|
||||
{ stage: 7, maxDailyVolume: 20000, durationDays: 5, targetMetrics: { maxBounceRate: 1, minOpenRate: 25 } },
|
||||
{ stage: 8, maxDailyVolume: 50000, durationDays: 5, targetMetrics: { maxBounceRate: 1, minOpenRate: 25 } },
|
||||
],
|
||||
startDate: new Date(),
|
||||
// Default hourly distribution (percentage per hour, sums to 100%)
|
||||
hourlyDistribution: [
|
||||
1, 1, 1, 1, 1, 2, 3, 5, 7, 8, 10, 11,
|
||||
10, 9, 8, 6, 5, 4, 3, 2, 1, 1, 1, 0
|
||||
],
|
||||
autoAdvanceStages: true,
|
||||
suspendOnMetricDecline: true,
|
||||
fallbackPercentage: 50,
|
||||
prioritizeEngagedSubscribers: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages the IP warming process for new sending IPs
|
||||
*/
|
||||
export class IPWarmupManager {
|
||||
private static instance: IPWarmupManager;
|
||||
private config: Required<IIPWarmupConfig>;
|
||||
private warmupStatuses: Map<string, IIPWarmupStatus> = new Map();
|
||||
private dailySendCounts: Map<string, number> = new Map();
|
||||
private hourlySendCounts: Map<string, number[]> = new Map();
|
||||
private isInitialized: boolean = false;
|
||||
private allocationPolicies: Map<string, IIPAllocationPolicy> = new Map();
|
||||
private activePolicy: string = 'balanced';
|
||||
|
||||
/**
|
||||
* Constructor for IPWarmupManager
|
||||
* @param config Warmup configuration
|
||||
*/
|
||||
constructor(config: IIPWarmupConfig = {}) {
|
||||
this.config = {
|
||||
...DEFAULT_WARMUP_CONFIG,
|
||||
...config,
|
||||
stages: config.stages || [...DEFAULT_WARMUP_CONFIG.stages]
|
||||
};
|
||||
|
||||
// Register default allocation policies
|
||||
this.registerAllocationPolicy('balanced', new BalancedAllocationPolicy());
|
||||
this.registerAllocationPolicy('roundRobin', new RoundRobinAllocationPolicy());
|
||||
this.registerAllocationPolicy('dedicated', new DedicatedDomainPolicy());
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of IPWarmupManager
|
||||
* @param config Warmup configuration
|
||||
* @returns Singleton instance
|
||||
*/
|
||||
public static getInstance(config: IIPWarmupConfig = {}): IPWarmupManager {
|
||||
if (!IPWarmupManager.instance) {
|
||||
IPWarmupManager.instance = new IPWarmupManager(config);
|
||||
}
|
||||
return IPWarmupManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the warmup manager
|
||||
*/
|
||||
private initialize(): void {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
// Load warmup statuses from storage
|
||||
this.loadWarmupStatuses();
|
||||
|
||||
// Initialize any new IPs that might have been added to config
|
||||
for (const ip of this.config.ipAddresses) {
|
||||
if (!this.warmupStatuses.has(ip)) {
|
||||
this.initializeIPWarmup(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize daily and hourly counters
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
for (const ip of this.config.ipAddresses) {
|
||||
this.dailySendCounts.set(ip, 0);
|
||||
this.hourlySendCounts.set(ip, Array(24).fill(0));
|
||||
}
|
||||
|
||||
// Schedule daily reset of counters
|
||||
this.scheduleDailyReset();
|
||||
|
||||
// Schedule daily evaluation of warmup progress
|
||||
this.scheduleDailyEvaluation();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.log('info', `IP Warmup Manager initialized with ${this.config.ipAddresses.length} IPs`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to initialize IP Warmup Manager: ${error.message}`, {
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize warmup status for a new IP address
|
||||
* @param ipAddress IP address to initialize
|
||||
*/
|
||||
private initializeIPWarmup(ipAddress: string): void {
|
||||
const startDate = new Date();
|
||||
let targetCompletionDate = new Date(startDate);
|
||||
|
||||
// Calculate target completion date based on stages
|
||||
let totalDays = 0;
|
||||
for (const stage of this.config.stages) {
|
||||
totalDays += stage.durationDays;
|
||||
}
|
||||
|
||||
targetCompletionDate.setDate(targetCompletionDate.getDate() + totalDays);
|
||||
|
||||
const warmupStatus: IIPWarmupStatus = {
|
||||
ipAddress,
|
||||
currentStage: 1,
|
||||
startDate,
|
||||
currentStageStartDate: new Date(),
|
||||
targetCompletionDate,
|
||||
currentDailyAllocation: this.config.stages[0].maxDailyVolume,
|
||||
sentInCurrentStage: 0,
|
||||
totalSent: 0,
|
||||
isActive: true,
|
||||
dailyStats: [],
|
||||
metrics: {
|
||||
openRate: 0,
|
||||
bounceRate: 0,
|
||||
complaintRate: 0
|
||||
}
|
||||
};
|
||||
|
||||
this.warmupStatuses.set(ipAddress, warmupStatus);
|
||||
this.saveWarmupStatuses();
|
||||
|
||||
logger.log('info', `Initialized warmup for IP ${ipAddress}`, {
|
||||
currentStage: 1,
|
||||
targetCompletion: targetCompletionDate.toISOString().split('T')[0]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule daily reset of send counters
|
||||
*/
|
||||
private scheduleDailyReset(): void {
|
||||
// Calculate time until midnight
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
|
||||
const timeUntilMidnight = tomorrow.getTime() - now.getTime();
|
||||
|
||||
// Schedule reset
|
||||
setTimeout(() => {
|
||||
this.resetDailyCounts();
|
||||
// Reschedule for next day
|
||||
this.scheduleDailyReset();
|
||||
}, timeUntilMidnight);
|
||||
|
||||
logger.log('info', `Scheduled daily counter reset in ${Math.floor(timeUntilMidnight / 60000)} minutes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset daily send counters
|
||||
*/
|
||||
private resetDailyCounts(): void {
|
||||
for (const ip of this.config.ipAddresses) {
|
||||
// Save yesterday's count to history before resetting
|
||||
const status = this.warmupStatuses.get(ip);
|
||||
if (status) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Update daily stats with yesterday's data
|
||||
const sentCount = this.dailySendCounts.get(ip) || 0;
|
||||
status.dailyStats.push({
|
||||
date: yesterday.toISOString().split('T')[0],
|
||||
sent: sentCount,
|
||||
opened: Math.floor(sentCount * status.metrics.openRate / 100),
|
||||
bounces: Math.floor(sentCount * status.metrics.bounceRate / 100),
|
||||
complaints: Math.floor(sentCount * status.metrics.complaintRate / 100)
|
||||
});
|
||||
|
||||
// Keep only the last 7 days of stats
|
||||
if (status.dailyStats.length > 7) {
|
||||
status.dailyStats.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Reset counters for today
|
||||
this.dailySendCounts.set(ip, 0);
|
||||
this.hourlySendCounts.set(ip, Array(24).fill(0));
|
||||
}
|
||||
|
||||
// Save updated statuses
|
||||
this.saveWarmupStatuses();
|
||||
|
||||
logger.log('info', 'Daily send counters reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule daily evaluation of warmup progress
|
||||
*/
|
||||
private scheduleDailyEvaluation(): void {
|
||||
// Calculate time until 1 AM (do evaluation after midnight)
|
||||
const now = new Date();
|
||||
const evaluationTime = new Date(now);
|
||||
evaluationTime.setDate(evaluationTime.getDate() + 1);
|
||||
evaluationTime.setHours(1, 0, 0, 0);
|
||||
|
||||
const timeUntilEvaluation = evaluationTime.getTime() - now.getTime();
|
||||
|
||||
// Schedule evaluation
|
||||
setTimeout(() => {
|
||||
this.evaluateWarmupProgress();
|
||||
// Reschedule for next day
|
||||
this.scheduleDailyEvaluation();
|
||||
}, timeUntilEvaluation);
|
||||
|
||||
logger.log('info', `Scheduled daily warmup evaluation in ${Math.floor(timeUntilEvaluation / 60000)} minutes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate warmup progress and possibly advance stages
|
||||
*/
|
||||
private evaluateWarmupProgress(): void {
|
||||
if (!this.config.autoAdvanceStages) {
|
||||
logger.log('info', 'Auto-advance stages is disabled, skipping evaluation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert entries to array for compatibility with older JS versions
|
||||
Array.from(this.warmupStatuses.entries()).forEach(([ip, status]) => {
|
||||
if (!status.isActive) return;
|
||||
|
||||
// Check if current stage duration has elapsed
|
||||
const currentStage = this.config.stages[status.currentStage - 1];
|
||||
const now = new Date();
|
||||
const daysSinceStageStart = Math.floor(
|
||||
(now.getTime() - status.currentStageStartDate.getTime()) / (24 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
if (daysSinceStageStart >= currentStage.durationDays) {
|
||||
// Check if metrics meet requirements for advancing
|
||||
const metricsOK = this.checkStageMetrics(status, currentStage);
|
||||
|
||||
if (metricsOK) {
|
||||
// Advance to next stage if not at the final stage
|
||||
if (status.currentStage < this.config.stages.length) {
|
||||
this.advanceToNextStage(ip);
|
||||
} else {
|
||||
logger.log('info', `IP ${ip} has completed the warmup process`);
|
||||
}
|
||||
} else if (this.config.suspendOnMetricDecline) {
|
||||
// Suspend warmup if metrics don't meet requirements
|
||||
status.isActive = false;
|
||||
logger.log('warn', `Suspended warmup for IP ${ip} due to poor metrics`, {
|
||||
openRate: status.metrics.openRate,
|
||||
bounceRate: status.metrics.bounceRate,
|
||||
complaintRate: status.metrics.complaintRate
|
||||
});
|
||||
} else {
|
||||
// Extend current stage if metrics don't meet requirements
|
||||
logger.log('info', `Extending stage ${status.currentStage} for IP ${ip} due to metrics not meeting requirements`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save updated statuses
|
||||
this.saveWarmupStatuses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current metrics meet the requirements for the stage
|
||||
* @param status Warmup status to check
|
||||
* @param stage Stage to check against
|
||||
* @returns Whether metrics meet requirements
|
||||
*/
|
||||
private checkStageMetrics(status: IIPWarmupStatus, stage: IWarmupStage): boolean {
|
||||
// If no target metrics specified, assume met
|
||||
if (!stage.targetMetrics) return true;
|
||||
|
||||
const metrics = status.metrics;
|
||||
let meetsRequirements = true;
|
||||
|
||||
// Check each metric against requirements
|
||||
if (stage.targetMetrics.minOpenRate !== undefined &&
|
||||
metrics.openRate < stage.targetMetrics.minOpenRate) {
|
||||
meetsRequirements = false;
|
||||
logger.log('info', `Open rate ${metrics.openRate}% below target ${stage.targetMetrics.minOpenRate}% for IP ${status.ipAddress}`);
|
||||
}
|
||||
|
||||
if (stage.targetMetrics.maxBounceRate !== undefined &&
|
||||
metrics.bounceRate > stage.targetMetrics.maxBounceRate) {
|
||||
meetsRequirements = false;
|
||||
logger.log('info', `Bounce rate ${metrics.bounceRate}% above target ${stage.targetMetrics.maxBounceRate}% for IP ${status.ipAddress}`);
|
||||
}
|
||||
|
||||
if (stage.targetMetrics.maxComplaintRate !== undefined &&
|
||||
metrics.complaintRate > stage.targetMetrics.maxComplaintRate) {
|
||||
meetsRequirements = false;
|
||||
logger.log('info', `Complaint rate ${metrics.complaintRate}% above target ${stage.targetMetrics.maxComplaintRate}% for IP ${status.ipAddress}`);
|
||||
}
|
||||
|
||||
return meetsRequirements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance IP to the next warmup stage
|
||||
* @param ipAddress IP address to advance
|
||||
*/
|
||||
private advanceToNextStage(ipAddress: string): void {
|
||||
const status = this.warmupStatuses.get(ipAddress);
|
||||
if (!status) return;
|
||||
|
||||
// Store metrics for the completed stage
|
||||
const completedStage = status.currentStage;
|
||||
|
||||
// Advance to next stage
|
||||
status.currentStage++;
|
||||
status.currentStageStartDate = new Date();
|
||||
status.sentInCurrentStage = 0;
|
||||
|
||||
// Update allocation
|
||||
const newStage = this.config.stages[status.currentStage - 1];
|
||||
status.currentDailyAllocation = newStage.maxDailyVolume;
|
||||
|
||||
logger.log('info', `Advanced IP ${ipAddress} to warmup stage ${status.currentStage}`, {
|
||||
previousStage: completedStage,
|
||||
newDailyLimit: status.currentDailyAllocation,
|
||||
durationDays: newStage.durationDays
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get warmup status for all IPs or a specific IP
|
||||
* @param ipAddress Optional specific IP to get status for
|
||||
* @returns Warmup status information
|
||||
*/
|
||||
public getWarmupStatus(ipAddress?: string): IIPWarmupStatus | Map<string, IIPWarmupStatus> {
|
||||
if (ipAddress) {
|
||||
return this.warmupStatuses.get(ipAddress);
|
||||
}
|
||||
return this.warmupStatuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new IP address to the warmup process
|
||||
* @param ipAddress IP address to add
|
||||
*/
|
||||
public addIPToWarmup(ipAddress: string): void {
|
||||
if (this.config.ipAddresses.includes(ipAddress)) {
|
||||
logger.log('info', `IP ${ipAddress} is already in warmup`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to configuration
|
||||
this.config.ipAddresses.push(ipAddress);
|
||||
|
||||
// Initialize warmup
|
||||
this.initializeIPWarmup(ipAddress);
|
||||
|
||||
// Initialize counters
|
||||
this.dailySendCounts.set(ipAddress, 0);
|
||||
this.hourlySendCounts.set(ipAddress, Array(24).fill(0));
|
||||
|
||||
logger.log('info', `Added IP ${ipAddress} to warmup process`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an IP address from the warmup process
|
||||
* @param ipAddress IP address to remove
|
||||
*/
|
||||
public removeIPFromWarmup(ipAddress: string): void {
|
||||
const index = this.config.ipAddresses.indexOf(ipAddress);
|
||||
if (index === -1) {
|
||||
logger.log('info', `IP ${ipAddress} is not in warmup`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from configuration
|
||||
this.config.ipAddresses.splice(index, 1);
|
||||
|
||||
// Remove from statuses and counters
|
||||
this.warmupStatuses.delete(ipAddress);
|
||||
this.dailySendCounts.delete(ipAddress);
|
||||
this.hourlySendCounts.delete(ipAddress);
|
||||
|
||||
this.saveWarmupStatuses();
|
||||
|
||||
logger.log('info', `Removed IP ${ipAddress} from warmup process`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metrics for an IP address
|
||||
* @param ipAddress IP address to update
|
||||
* @param metrics New metrics
|
||||
*/
|
||||
public updateMetrics(
|
||||
ipAddress: string,
|
||||
metrics: { openRate?: number; bounceRate?: number; complaintRate?: number }
|
||||
): void {
|
||||
const status = this.warmupStatuses.get(ipAddress);
|
||||
if (!status) {
|
||||
logger.log('warn', `Cannot update metrics for IP ${ipAddress} - not in warmup`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update metrics
|
||||
if (metrics.openRate !== undefined) {
|
||||
status.metrics.openRate = metrics.openRate;
|
||||
}
|
||||
|
||||
if (metrics.bounceRate !== undefined) {
|
||||
status.metrics.bounceRate = metrics.bounceRate;
|
||||
}
|
||||
|
||||
if (metrics.complaintRate !== undefined) {
|
||||
status.metrics.complaintRate = metrics.complaintRate;
|
||||
}
|
||||
|
||||
this.saveWarmupStatuses();
|
||||
|
||||
logger.log('info', `Updated metrics for IP ${ipAddress}`, {
|
||||
openRate: status.metrics.openRate,
|
||||
bounceRate: status.metrics.bounceRate,
|
||||
complaintRate: status.metrics.complaintRate
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a send event for an IP address
|
||||
* @param ipAddress IP address used for sending
|
||||
*/
|
||||
public recordSend(ipAddress: string): void {
|
||||
if (!this.config.ipAddresses.includes(ipAddress)) {
|
||||
logger.log('warn', `Cannot record send for IP ${ipAddress} - not in warmup`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment daily counter
|
||||
const currentCount = this.dailySendCounts.get(ipAddress) || 0;
|
||||
this.dailySendCounts.set(ipAddress, currentCount + 1);
|
||||
|
||||
// Increment hourly counter
|
||||
const hourlyCount = this.hourlySendCounts.get(ipAddress) || Array(24).fill(0);
|
||||
const currentHour = new Date().getHours();
|
||||
hourlyCount[currentHour]++;
|
||||
this.hourlySendCounts.set(ipAddress, hourlyCount);
|
||||
|
||||
// Update warmup status
|
||||
const status = this.warmupStatuses.get(ipAddress);
|
||||
if (status) {
|
||||
status.sentInCurrentStage++;
|
||||
status.totalSent++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP can send more emails today
|
||||
* @param ipAddress IP address to check
|
||||
* @returns Whether the IP can send more emails
|
||||
*/
|
||||
public canSendMoreToday(ipAddress: string): boolean {
|
||||
if (!this.config.enabled) return true;
|
||||
|
||||
if (!this.config.ipAddresses.includes(ipAddress)) {
|
||||
// If not in warmup, assume it can send
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = this.warmupStatuses.get(ipAddress);
|
||||
if (!status || !status.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentCount = this.dailySendCounts.get(ipAddress) || 0;
|
||||
return currentCount < status.currentDailyAllocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP can send more emails in the current hour
|
||||
* @param ipAddress IP address to check
|
||||
* @returns Whether the IP can send more emails this hour
|
||||
*/
|
||||
public canSendMoreThisHour(ipAddress: string): boolean {
|
||||
if (!this.config.enabled) return true;
|
||||
|
||||
if (!this.config.ipAddresses.includes(ipAddress)) {
|
||||
// If not in warmup, assume it can send
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = this.warmupStatuses.get(ipAddress);
|
||||
if (!status || !status.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentDailyLimit = status.currentDailyAllocation;
|
||||
const currentHour = new Date().getHours();
|
||||
const hourlyAllocation = Math.ceil((currentDailyLimit * this.config.hourlyDistribution[currentHour]) / 100);
|
||||
|
||||
const hourlyCount = this.hourlySendCounts.get(ipAddress) || Array(24).fill(0);
|
||||
const currentHourCount = hourlyCount[currentHour];
|
||||
|
||||
return currentHourCount < hourlyAllocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best IP to use for sending an email
|
||||
* @param emailInfo Information about the email being sent
|
||||
* @returns The best IP to use, or null if no suitable IP is available
|
||||
*/
|
||||
public getBestIPForSending(emailInfo: {
|
||||
from: string;
|
||||
to: string[];
|
||||
domain: string;
|
||||
isTransactional?: boolean;
|
||||
}): string | null {
|
||||
// If warmup is disabled, return null (caller will use default IP)
|
||||
if (!this.config.enabled || this.config.ipAddresses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prepare information for allocation policy
|
||||
const availableIPs = this.config.ipAddresses
|
||||
.filter(ip => this.canSendMoreToday(ip) && this.canSendMoreThisHour(ip))
|
||||
.map(ip => {
|
||||
const status = this.warmupStatuses.get(ip);
|
||||
return {
|
||||
ip,
|
||||
priority: status ? status.currentStage : 1,
|
||||
capacity: status ? (status.currentDailyAllocation - (this.dailySendCounts.get(ip) || 0)) : 0
|
||||
};
|
||||
});
|
||||
|
||||
// Use the active allocation policy to determine the best IP
|
||||
const policy = this.allocationPolicies.get(this.activePolicy);
|
||||
if (!policy) {
|
||||
logger.log('warn', `No allocation policy named ${this.activePolicy} found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return policy.allocateIP(availableIPs, {
|
||||
...emailInfo,
|
||||
isTransactional: emailInfo.isTransactional || false,
|
||||
isWarmup: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new IP allocation policy
|
||||
* @param name Policy name
|
||||
* @param policy Policy implementation
|
||||
*/
|
||||
public registerAllocationPolicy(name: string, policy: IIPAllocationPolicy): void {
|
||||
this.allocationPolicies.set(name, policy);
|
||||
logger.log('info', `Registered IP allocation policy: ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active IP allocation policy
|
||||
* @param name Policy name
|
||||
*/
|
||||
public setActiveAllocationPolicy(name: string): void {
|
||||
if (!this.allocationPolicies.has(name)) {
|
||||
logger.log('warn', `No allocation policy named ${name} found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activePolicy = name;
|
||||
logger.log('info', `Set active IP allocation policy to ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of stages in the warmup process
|
||||
* @returns Number of stages
|
||||
*/
|
||||
public getStageCount(): number {
|
||||
return this.config.stages.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load warmup statuses from storage
|
||||
*/
|
||||
private loadWarmupStatuses(): void {
|
||||
try {
|
||||
const warmupDir = plugins.path.join(paths.dataDir, 'warmup');
|
||||
plugins.smartfile.fs.ensureDirSync(warmupDir);
|
||||
|
||||
const statusFile = plugins.path.join(warmupDir, 'ip_warmup_status.json');
|
||||
|
||||
if (plugins.fs.existsSync(statusFile)) {
|
||||
const data = plugins.fs.readFileSync(statusFile, 'utf8');
|
||||
const statuses = JSON.parse(data);
|
||||
|
||||
for (const status of statuses) {
|
||||
// Restore date objects
|
||||
status.startDate = new Date(status.startDate);
|
||||
status.currentStageStartDate = new Date(status.currentStageStartDate);
|
||||
status.targetCompletionDate = new Date(status.targetCompletionDate);
|
||||
|
||||
this.warmupStatuses.set(status.ipAddress, status);
|
||||
}
|
||||
|
||||
logger.log('info', `Loaded ${this.warmupStatuses.size} IP warmup statuses from storage`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load warmup statuses: ${error.message}`, {
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save warmup statuses to storage
|
||||
*/
|
||||
private saveWarmupStatuses(): void {
|
||||
try {
|
||||
const warmupDir = plugins.path.join(paths.dataDir, 'warmup');
|
||||
plugins.smartfile.fs.ensureDirSync(warmupDir);
|
||||
|
||||
const statusFile = plugins.path.join(warmupDir, 'ip_warmup_status.json');
|
||||
const statuses = Array.from(this.warmupStatuses.values());
|
||||
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
JSON.stringify(statuses, null, 2),
|
||||
statusFile
|
||||
);
|
||||
|
||||
logger.log('debug', `Saved ${statuses.length} IP warmup statuses to storage`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save warmup statuses: ${error.message}`, {
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy that balances traffic across IPs based on stage and capacity
|
||||
*/
|
||||
class BalancedAllocationPolicy implements IIPAllocationPolicy {
|
||||
name = 'balanced';
|
||||
|
||||
allocateIP(
|
||||
availableIPs: Array<{ ip: string; priority: number; capacity: number }>,
|
||||
emailInfo: {
|
||||
from: string;
|
||||
to: string[];
|
||||
domain: string;
|
||||
isTransactional: boolean;
|
||||
isWarmup: boolean;
|
||||
}
|
||||
): string | null {
|
||||
if (availableIPs.length === 0) return null;
|
||||
|
||||
// Sort IPs by priority (prefer higher stage IPs) and capacity
|
||||
const sortedIPs = [...availableIPs].sort((a, b) => {
|
||||
// First by priority (descending)
|
||||
if (b.priority !== a.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
// Then by remaining capacity (descending)
|
||||
return b.capacity - a.capacity;
|
||||
});
|
||||
|
||||
// Prioritize higher-stage IPs for transactional emails
|
||||
if (emailInfo.isTransactional) {
|
||||
return sortedIPs[0].ip;
|
||||
}
|
||||
|
||||
// For marketing emails, spread across IPs with preference for higher stages
|
||||
// Use weighted random selection based on stage
|
||||
const totalWeight = sortedIPs.reduce((sum, ip) => sum + ip.priority, 0);
|
||||
let randomPoint = Math.random() * totalWeight;
|
||||
|
||||
for (const ip of sortedIPs) {
|
||||
randomPoint -= ip.priority;
|
||||
if (randomPoint <= 0) {
|
||||
return ip.ip;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the highest priority IP
|
||||
return sortedIPs[0].ip;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy that rotates through IPs in a round-robin fashion
|
||||
*/
|
||||
class RoundRobinAllocationPolicy implements IIPAllocationPolicy {
|
||||
name = 'roundRobin';
|
||||
private lastIndex = -1;
|
||||
|
||||
allocateIP(
|
||||
availableIPs: Array<{ ip: string; priority: number; capacity: number }>,
|
||||
emailInfo: {
|
||||
from: string;
|
||||
to: string[];
|
||||
domain: string;
|
||||
isTransactional: boolean;
|
||||
isWarmup: boolean;
|
||||
}
|
||||
): string | null {
|
||||
if (availableIPs.length === 0) return null;
|
||||
|
||||
// Sort by capacity to ensure even distribution
|
||||
const sortedIPs = [...availableIPs].sort((a, b) => b.capacity - a.capacity);
|
||||
|
||||
// Move to next IP
|
||||
this.lastIndex = (this.lastIndex + 1) % sortedIPs.length;
|
||||
|
||||
return sortedIPs[this.lastIndex].ip;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy that dedicates specific IPs to specific domains
|
||||
*/
|
||||
class DedicatedDomainPolicy implements IIPAllocationPolicy {
|
||||
name = 'dedicated';
|
||||
private domainAssignments: Map<string, string> = new Map();
|
||||
|
||||
allocateIP(
|
||||
availableIPs: Array<{ ip: string; priority: number; capacity: number }>,
|
||||
emailInfo: {
|
||||
from: string;
|
||||
to: string[];
|
||||
domain: string;
|
||||
isTransactional: boolean;
|
||||
isWarmup: boolean;
|
||||
}
|
||||
): string | null {
|
||||
if (availableIPs.length === 0) return null;
|
||||
|
||||
// Check if we have a dedicated IP for this domain
|
||||
if (this.domainAssignments.has(emailInfo.domain)) {
|
||||
const dedicatedIP = this.domainAssignments.get(emailInfo.domain);
|
||||
|
||||
// Check if the dedicated IP is in the available list
|
||||
const isAvailable = availableIPs.some(ip => ip.ip === dedicatedIP);
|
||||
|
||||
if (isAvailable) {
|
||||
return dedicatedIP;
|
||||
}
|
||||
}
|
||||
|
||||
// If not, assign one and save the assignment
|
||||
const sortedIPs = [...availableIPs].sort((a, b) => b.capacity - a.capacity);
|
||||
const assignedIP = sortedIPs[0].ip;
|
||||
|
||||
this.domainAssignments.set(emailInfo.domain, assignedIP);
|
||||
|
||||
return assignedIP;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
export {
|
||||
IPWarmupManager,
|
||||
type IIPWarmupConfig,
|
||||
type IWarmupStage,
|
||||
type IIPWarmupStatus,
|
||||
type IIPAllocationPolicy
|
||||
} from './classes.ipwarmupmanager.js';
|
||||
|
||||
export {
|
||||
SenderReputationMonitor,
|
||||
type IDomainReputationMetrics,
|
||||
type IReputationMonitorConfig
|
||||
} from './classes.senderreputationmonitor.js';
|
||||
@@ -1,87 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EmailService } from './classes.emailservice.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export class ApiManager {
|
||||
public emailRef: EmailService;
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(emailRefArg: EmailService) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.emailRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
|
||||
// Register API endpoints
|
||||
this.registerApiEndpoints();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register API endpoints for email functionality
|
||||
*/
|
||||
private registerApiEndpoints() {
|
||||
// Register the SendEmail endpoint
|
||||
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_SendEmail>(
|
||||
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
|
||||
const mailToSend = new plugins.smartmail.Smartmail({
|
||||
body: requestData.body,
|
||||
from: requestData.from,
|
||||
subject: requestData.title,
|
||||
});
|
||||
|
||||
if (requestData.attachments) {
|
||||
for (const attachment of requestData.attachments) {
|
||||
mailToSend.addAttachment(
|
||||
await plugins.smartfile.SmartFile.fromString(
|
||||
attachment.name,
|
||||
attachment.binaryAttachmentString,
|
||||
'binary'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email through the service which will route to the appropriate connector
|
||||
const emailId = await this.emailRef.sendEmail(mailToSend, requestData.to, {});
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`sent an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
|
||||
{
|
||||
eventType: 'sentEmail',
|
||||
email: {
|
||||
to: requestData.to,
|
||||
subject: mailToSend.getSubject(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
responseId: emailId,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Add endpoint to check email status
|
||||
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_CheckEmailStatus>(
|
||||
new plugins.typedrequest.TypedHandler('checkEmailStatus', async (requestData) => {
|
||||
// If MTA is enabled, use it to check status
|
||||
if (this.emailRef.mtaConnector) {
|
||||
const status = await this.emailRef.mtaConnector.checkEmailStatus(requestData.emailId);
|
||||
return status;
|
||||
}
|
||||
|
||||
// For Mailgun, we don't have a status check implementation currently
|
||||
return {
|
||||
status: 'unknown',
|
||||
details: { message: 'Status tracking not available for current provider' }
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Add statistics endpoint
|
||||
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_GetEMailStats>(
|
||||
new plugins.typedrequest.TypedHandler('getEmailStats', async () => {
|
||||
return this.emailRef.getStats();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,902 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
/**
|
||||
* Bounce types for categorizing the reasons for bounces
|
||||
*/
|
||||
export enum BounceType {
|
||||
// Hard bounces (permanent failures)
|
||||
INVALID_RECIPIENT = 'invalid_recipient',
|
||||
DOMAIN_NOT_FOUND = 'domain_not_found',
|
||||
MAILBOX_FULL = 'mailbox_full',
|
||||
MAILBOX_INACTIVE = 'mailbox_inactive',
|
||||
BLOCKED = 'blocked',
|
||||
SPAM_RELATED = 'spam_related',
|
||||
POLICY_RELATED = 'policy_related',
|
||||
|
||||
// Soft bounces (temporary failures)
|
||||
SERVER_UNAVAILABLE = 'server_unavailable',
|
||||
TEMPORARY_FAILURE = 'temporary_failure',
|
||||
QUOTA_EXCEEDED = 'quota_exceeded',
|
||||
NETWORK_ERROR = 'network_error',
|
||||
TIMEOUT = 'timeout',
|
||||
|
||||
// Special cases
|
||||
AUTO_RESPONSE = 'auto_response',
|
||||
CHALLENGE_RESPONSE = 'challenge_response',
|
||||
UNKNOWN = 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard vs soft bounce classification
|
||||
*/
|
||||
export enum BounceCategory {
|
||||
HARD = 'hard',
|
||||
SOFT = 'soft',
|
||||
AUTO_RESPONSE = 'auto_response',
|
||||
UNKNOWN = 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce data structure
|
||||
*/
|
||||
export interface BounceRecord {
|
||||
id: string;
|
||||
originalEmailId?: string;
|
||||
recipient: string;
|
||||
sender: string;
|
||||
domain: string;
|
||||
subject?: string;
|
||||
bounceType: BounceType;
|
||||
bounceCategory: BounceCategory;
|
||||
timestamp: number;
|
||||
smtpResponse?: string;
|
||||
diagnosticCode?: string;
|
||||
statusCode?: string;
|
||||
headers?: Record<string, string>;
|
||||
processed: boolean;
|
||||
retryCount?: number;
|
||||
nextRetryTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email bounce patterns to identify bounce types in SMTP responses and bounce messages
|
||||
*/
|
||||
const BOUNCE_PATTERNS = {
|
||||
// Hard bounce patterns
|
||||
[BounceType.INVALID_RECIPIENT]: [
|
||||
/no such user/i,
|
||||
/user unknown/i,
|
||||
/does not exist/i,
|
||||
/invalid recipient/i,
|
||||
/unknown recipient/i,
|
||||
/no mailbox/i,
|
||||
/user not found/i,
|
||||
/recipient address rejected/i,
|
||||
/550 5\.1\.1/i
|
||||
],
|
||||
[BounceType.DOMAIN_NOT_FOUND]: [
|
||||
/domain not found/i,
|
||||
/unknown domain/i,
|
||||
/no such domain/i,
|
||||
/host not found/i,
|
||||
/domain invalid/i,
|
||||
/550 5\.1\.2/i
|
||||
],
|
||||
[BounceType.MAILBOX_FULL]: [
|
||||
/mailbox full/i,
|
||||
/over quota/i,
|
||||
/quota exceeded/i,
|
||||
/552 5\.2\.2/i
|
||||
],
|
||||
[BounceType.MAILBOX_INACTIVE]: [
|
||||
/mailbox disabled/i,
|
||||
/mailbox inactive/i,
|
||||
/account disabled/i,
|
||||
/mailbox not active/i,
|
||||
/account suspended/i
|
||||
],
|
||||
[BounceType.BLOCKED]: [
|
||||
/blocked/i,
|
||||
/rejected/i,
|
||||
/denied/i,
|
||||
/blacklisted/i,
|
||||
/prohibited/i,
|
||||
/refused/i,
|
||||
/550 5\.7\./i
|
||||
],
|
||||
[BounceType.SPAM_RELATED]: [
|
||||
/spam/i,
|
||||
/bulk mail/i,
|
||||
/content rejected/i,
|
||||
/message rejected/i,
|
||||
/550 5\.7\.1/i
|
||||
],
|
||||
|
||||
// Soft bounce patterns
|
||||
[BounceType.SERVER_UNAVAILABLE]: [
|
||||
/server unavailable/i,
|
||||
/service unavailable/i,
|
||||
/try again later/i,
|
||||
/try later/i,
|
||||
/451 4\.3\./i,
|
||||
/421 4\.3\./i
|
||||
],
|
||||
[BounceType.TEMPORARY_FAILURE]: [
|
||||
/temporary failure/i,
|
||||
/temporary error/i,
|
||||
/temporary problem/i,
|
||||
/try again/i,
|
||||
/451 4\./i
|
||||
],
|
||||
[BounceType.QUOTA_EXCEEDED]: [
|
||||
/quota temporarily exceeded/i,
|
||||
/mailbox temporarily full/i,
|
||||
/452 4\.2\.2/i
|
||||
],
|
||||
[BounceType.NETWORK_ERROR]: [
|
||||
/network error/i,
|
||||
/connection error/i,
|
||||
/connection timed out/i,
|
||||
/routing error/i,
|
||||
/421 4\.4\./i
|
||||
],
|
||||
[BounceType.TIMEOUT]: [
|
||||
/timed out/i,
|
||||
/timeout/i,
|
||||
/450 4\.4\.2/i
|
||||
],
|
||||
|
||||
// Auto-responses
|
||||
[BounceType.AUTO_RESPONSE]: [
|
||||
/auto[- ]reply/i,
|
||||
/auto[- ]response/i,
|
||||
/vacation/i,
|
||||
/out of office/i,
|
||||
/away from office/i,
|
||||
/on vacation/i,
|
||||
/automatic reply/i
|
||||
],
|
||||
[BounceType.CHALLENGE_RESPONSE]: [
|
||||
/challenge[- ]response/i,
|
||||
/verify your email/i,
|
||||
/confirm your email/i,
|
||||
/email verification/i
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry strategy configuration for soft bounces
|
||||
*/
|
||||
interface RetryStrategy {
|
||||
maxRetries: number;
|
||||
initialDelay: number; // milliseconds
|
||||
maxDelay: number; // milliseconds
|
||||
backoffFactor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager for handling email bounces
|
||||
*/
|
||||
export class BounceManager {
|
||||
// Retry strategy with exponential backoff
|
||||
private retryStrategy: RetryStrategy = {
|
||||
maxRetries: 5,
|
||||
initialDelay: 15 * 60 * 1000, // 15 minutes
|
||||
maxDelay: 24 * 60 * 60 * 1000, // 24 hours
|
||||
backoffFactor: 2
|
||||
};
|
||||
|
||||
// Store of bounced emails
|
||||
private bounceStore: BounceRecord[] = [];
|
||||
|
||||
// Cache of recently bounced email addresses to avoid sending to known bad addresses
|
||||
private bounceCache: LRUCache<string, {
|
||||
lastBounce: number;
|
||||
count: number;
|
||||
type: BounceType;
|
||||
category: BounceCategory;
|
||||
}>;
|
||||
|
||||
// Suppression list for addresses that should not receive emails
|
||||
private suppressionList: Map<string, {
|
||||
reason: string;
|
||||
timestamp: number;
|
||||
expiresAt?: number; // undefined means permanent
|
||||
}> = new Map();
|
||||
|
||||
constructor(options?: {
|
||||
retryStrategy?: Partial<RetryStrategy>;
|
||||
maxCacheSize?: number;
|
||||
cacheTTL?: number;
|
||||
}) {
|
||||
// Set retry strategy with defaults
|
||||
if (options?.retryStrategy) {
|
||||
this.retryStrategy = {
|
||||
...this.retryStrategy,
|
||||
...options.retryStrategy
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize bounce cache with LRU (least recently used) caching
|
||||
this.bounceCache = new LRUCache<string, any>({
|
||||
max: options?.maxCacheSize || 10000,
|
||||
ttl: options?.cacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days default
|
||||
});
|
||||
|
||||
// Load suppression list from storage
|
||||
this.loadSuppressionList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a bounce notification
|
||||
* @param bounceData Bounce data to process
|
||||
* @returns Processed bounce record
|
||||
*/
|
||||
public async processBounce(bounceData: Partial<BounceRecord>): Promise<BounceRecord> {
|
||||
try {
|
||||
// Add required fields if missing
|
||||
const bounce: BounceRecord = {
|
||||
id: bounceData.id || plugins.uuid.v4(),
|
||||
recipient: bounceData.recipient,
|
||||
sender: bounceData.sender,
|
||||
domain: bounceData.domain || bounceData.recipient.split('@')[1],
|
||||
subject: bounceData.subject,
|
||||
bounceType: bounceData.bounceType || BounceType.UNKNOWN,
|
||||
bounceCategory: bounceData.bounceCategory || BounceCategory.UNKNOWN,
|
||||
timestamp: bounceData.timestamp || Date.now(),
|
||||
smtpResponse: bounceData.smtpResponse,
|
||||
diagnosticCode: bounceData.diagnosticCode,
|
||||
statusCode: bounceData.statusCode,
|
||||
headers: bounceData.headers,
|
||||
processed: false,
|
||||
originalEmailId: bounceData.originalEmailId,
|
||||
retryCount: bounceData.retryCount || 0,
|
||||
nextRetryTime: bounceData.nextRetryTime
|
||||
};
|
||||
|
||||
// Determine bounce type and category if not provided
|
||||
if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
|
||||
const bounceInfo = this.detectBounceType(
|
||||
bounce.smtpResponse || '',
|
||||
bounce.diagnosticCode || '',
|
||||
bounce.statusCode || ''
|
||||
);
|
||||
|
||||
bounce.bounceType = bounceInfo.type;
|
||||
bounce.bounceCategory = bounceInfo.category;
|
||||
}
|
||||
|
||||
// Process the bounce based on category
|
||||
switch (bounce.bounceCategory) {
|
||||
case BounceCategory.HARD:
|
||||
// Handle hard bounce - add to suppression list
|
||||
await this.handleHardBounce(bounce);
|
||||
break;
|
||||
|
||||
case BounceCategory.SOFT:
|
||||
// Handle soft bounce - schedule retry if eligible
|
||||
await this.handleSoftBounce(bounce);
|
||||
break;
|
||||
|
||||
case BounceCategory.AUTO_RESPONSE:
|
||||
// Handle auto-response - typically no action needed
|
||||
logger.log('info', `Auto-response detected for ${bounce.recipient}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown bounce type - log for investigation
|
||||
logger.log('warn', `Unknown bounce type for ${bounce.recipient}`, {
|
||||
bounceType: bounce.bounceType,
|
||||
smtpResponse: bounce.smtpResponse
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Store the bounce record
|
||||
bounce.processed = true;
|
||||
this.bounceStore.push(bounce);
|
||||
|
||||
// Update the bounce cache
|
||||
this.updateBounceCache(bounce);
|
||||
|
||||
// Log the bounce
|
||||
logger.log(
|
||||
bounce.bounceCategory === BounceCategory.HARD ? 'warn' : 'info',
|
||||
`Email bounce processed: ${bounce.bounceCategory} bounce for ${bounce.recipient}`,
|
||||
{
|
||||
bounceType: bounce.bounceType,
|
||||
domain: bounce.domain,
|
||||
category: bounce.bounceCategory
|
||||
}
|
||||
);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: bounce.bounceCategory === BounceCategory.HARD
|
||||
? SecurityLogLevel.WARN
|
||||
: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.EMAIL_VALIDATION,
|
||||
message: `Email bounce detected: ${bounce.bounceCategory} bounce for recipient`,
|
||||
domain: bounce.domain,
|
||||
details: {
|
||||
recipient: bounce.recipient,
|
||||
bounceType: bounce.bounceType,
|
||||
smtpResponse: bounce.smtpResponse,
|
||||
diagnosticCode: bounce.diagnosticCode,
|
||||
statusCode: bounce.statusCode
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return bounce;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error processing bounce: ${error.message}`, {
|
||||
error: error.message,
|
||||
bounceData
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an SMTP failure as a bounce
|
||||
* @param recipient Recipient email
|
||||
* @param smtpResponse SMTP error response
|
||||
* @param options Additional options
|
||||
* @returns Processed bounce record
|
||||
*/
|
||||
public async processSmtpFailure(
|
||||
recipient: string,
|
||||
smtpResponse: string,
|
||||
options: {
|
||||
sender?: string;
|
||||
originalEmailId?: string;
|
||||
statusCode?: string;
|
||||
headers?: Record<string, string>;
|
||||
} = {}
|
||||
): Promise<BounceRecord> {
|
||||
// Create bounce data from SMTP failure
|
||||
const bounceData: Partial<BounceRecord> = {
|
||||
recipient,
|
||||
sender: options.sender || '',
|
||||
domain: recipient.split('@')[1],
|
||||
smtpResponse,
|
||||
statusCode: options.statusCode,
|
||||
headers: options.headers,
|
||||
originalEmailId: options.originalEmailId,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Process as a regular bounce
|
||||
return this.processBounce(bounceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a bounce notification email
|
||||
* @param bounceEmail The email containing bounce information
|
||||
* @returns Processed bounce record or null if not a bounce
|
||||
*/
|
||||
public async processBounceEmail(bounceEmail: plugins.smartmail.Smartmail<any>): Promise<BounceRecord | null> {
|
||||
try {
|
||||
// Check if this is a bounce notification
|
||||
const subject = bounceEmail.getSubject();
|
||||
const body = bounceEmail.getBody();
|
||||
|
||||
// Check for common bounce notification subject patterns
|
||||
const isBounceSubject = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
|
||||
|
||||
if (!isBounceSubject) {
|
||||
// Not a bounce notification based on subject
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract original recipient from the body or headers
|
||||
let recipient = '';
|
||||
let originalMessageId = '';
|
||||
|
||||
// Extract recipient from common bounce formats
|
||||
const recipientMatch = body.match(/(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>?/i);
|
||||
if (recipientMatch && recipientMatch[1]) {
|
||||
recipient = recipientMatch[1];
|
||||
}
|
||||
|
||||
// Extract diagnostic code
|
||||
let diagnosticCode = '';
|
||||
const diagnosticMatch = body.match(/diagnostic(?:-|\\s+)code:\s*(.+)(?:\n|$)/i);
|
||||
if (diagnosticMatch && diagnosticMatch[1]) {
|
||||
diagnosticCode = diagnosticMatch[1].trim();
|
||||
}
|
||||
|
||||
// Extract SMTP status code
|
||||
let statusCode = '';
|
||||
const statusMatch = body.match(/status(?:-|\\s+)code:\s*([0-9.]+)/i);
|
||||
if (statusMatch && statusMatch[1]) {
|
||||
statusCode = statusMatch[1].trim();
|
||||
}
|
||||
|
||||
// If recipient not found in standard patterns, try DSN (Delivery Status Notification) format
|
||||
if (!recipient) {
|
||||
// Look for DSN format with Original-Recipient or Final-Recipient fields
|
||||
const originalRecipientMatch = body.match(/original-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
const finalRecipientMatch = body.match(/final-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (originalRecipientMatch && originalRecipientMatch[1]) {
|
||||
recipient = originalRecipientMatch[1];
|
||||
} else if (finalRecipientMatch && finalRecipientMatch[1]) {
|
||||
recipient = finalRecipientMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// If still no recipient, can't process as bounce
|
||||
if (!recipient) {
|
||||
logger.log('warn', 'Could not extract recipient from bounce notification', {
|
||||
subject,
|
||||
sender: bounceEmail.options.from
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract original message ID if available
|
||||
const messageIdMatch = body.match(/original[ -]message[ -]id:[ \t]*<?([^>]+)>?/i);
|
||||
if (messageIdMatch && messageIdMatch[1]) {
|
||||
originalMessageId = messageIdMatch[1].trim();
|
||||
}
|
||||
|
||||
// Create bounce data
|
||||
const bounceData: Partial<BounceRecord> = {
|
||||
recipient,
|
||||
sender: bounceEmail.options.from,
|
||||
domain: recipient.split('@')[1],
|
||||
subject: bounceEmail.getSubject(),
|
||||
diagnosticCode,
|
||||
statusCode,
|
||||
timestamp: Date.now(),
|
||||
headers: {}
|
||||
};
|
||||
|
||||
// Process as a regular bounce
|
||||
return this.processBounce(bounceData);
|
||||
} catch (error) {
|
||||
logger.log('error', `Error processing bounce email: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a hard bounce by adding to suppression list
|
||||
* @param bounce The bounce record
|
||||
*/
|
||||
private async handleHardBounce(bounce: BounceRecord): Promise<void> {
|
||||
// Add to suppression list permanently (no expiry)
|
||||
this.addToSuppressionList(bounce.recipient, `Hard bounce: ${bounce.bounceType}`, undefined);
|
||||
|
||||
// Increment bounce count in cache
|
||||
this.updateBounceCache(bounce);
|
||||
|
||||
// Save to permanent storage
|
||||
this.saveBounceRecord(bounce);
|
||||
|
||||
// Log hard bounce for monitoring
|
||||
logger.log('warn', `Hard bounce for ${bounce.recipient}: ${bounce.bounceType}`, {
|
||||
domain: bounce.domain,
|
||||
smtpResponse: bounce.smtpResponse,
|
||||
diagnosticCode: bounce.diagnosticCode
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a soft bounce by scheduling a retry if eligible
|
||||
* @param bounce The bounce record
|
||||
*/
|
||||
private async handleSoftBounce(bounce: BounceRecord): Promise<void> {
|
||||
// Check if we've exceeded max retries
|
||||
if (bounce.retryCount >= this.retryStrategy.maxRetries) {
|
||||
logger.log('warn', `Max retries exceeded for ${bounce.recipient}, treating as hard bounce`);
|
||||
|
||||
// Convert to hard bounce after max retries
|
||||
bounce.bounceCategory = BounceCategory.HARD;
|
||||
await this.handleHardBounce(bounce);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate next retry time with exponential backoff
|
||||
const delay = Math.min(
|
||||
this.retryStrategy.initialDelay * Math.pow(this.retryStrategy.backoffFactor, bounce.retryCount),
|
||||
this.retryStrategy.maxDelay
|
||||
);
|
||||
|
||||
bounce.retryCount++;
|
||||
bounce.nextRetryTime = Date.now() + delay;
|
||||
|
||||
// Add to suppression list temporarily (with expiry)
|
||||
this.addToSuppressionList(
|
||||
bounce.recipient,
|
||||
`Soft bounce: ${bounce.bounceType}`,
|
||||
bounce.nextRetryTime
|
||||
);
|
||||
|
||||
// Log the retry schedule
|
||||
logger.log('info', `Scheduled retry ${bounce.retryCount} for ${bounce.recipient} at ${new Date(bounce.nextRetryTime).toISOString()}`, {
|
||||
bounceType: bounce.bounceType,
|
||||
retryCount: bounce.retryCount,
|
||||
nextRetry: bounce.nextRetryTime
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an email address to the suppression list
|
||||
* @param email Email address to suppress
|
||||
* @param reason Reason for suppression
|
||||
* @param expiresAt Expiration timestamp (undefined for permanent)
|
||||
*/
|
||||
public addToSuppressionList(
|
||||
email: string,
|
||||
reason: string,
|
||||
expiresAt?: number
|
||||
): void {
|
||||
this.suppressionList.set(email.toLowerCase(), {
|
||||
reason,
|
||||
timestamp: Date.now(),
|
||||
expiresAt
|
||||
});
|
||||
|
||||
this.saveSuppressionList();
|
||||
|
||||
logger.log('info', `Added ${email} to suppression list`, {
|
||||
reason,
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : 'permanent'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an email address from the suppression list
|
||||
* @param email Email address to remove
|
||||
*/
|
||||
public removeFromSuppressionList(email: string): void {
|
||||
const wasRemoved = this.suppressionList.delete(email.toLowerCase());
|
||||
|
||||
if (wasRemoved) {
|
||||
this.saveSuppressionList();
|
||||
logger.log('info', `Removed ${email} from suppression list`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an email is on the suppression list
|
||||
* @param email Email address to check
|
||||
* @returns Whether the email is suppressed
|
||||
*/
|
||||
public isEmailSuppressed(email: string): boolean {
|
||||
const lowercaseEmail = email.toLowerCase();
|
||||
const suppression = this.suppressionList.get(lowercaseEmail);
|
||||
|
||||
if (!suppression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if suppression has expired
|
||||
if (suppression.expiresAt && Date.now() > suppression.expiresAt) {
|
||||
this.suppressionList.delete(lowercaseEmail);
|
||||
this.saveSuppressionList();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suppression information for an email
|
||||
* @param email Email address to check
|
||||
* @returns Suppression information or null if not suppressed
|
||||
*/
|
||||
public getSuppressionInfo(email: string): {
|
||||
reason: string;
|
||||
timestamp: number;
|
||||
expiresAt?: number;
|
||||
} | null {
|
||||
const lowercaseEmail = email.toLowerCase();
|
||||
const suppression = this.suppressionList.get(lowercaseEmail);
|
||||
|
||||
if (!suppression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if suppression has expired
|
||||
if (suppression.expiresAt && Date.now() > suppression.expiresAt) {
|
||||
this.suppressionList.delete(lowercaseEmail);
|
||||
this.saveSuppressionList();
|
||||
return null;
|
||||
}
|
||||
|
||||
return suppression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save suppression list to disk
|
||||
*/
|
||||
private saveSuppressionList(): void {
|
||||
try {
|
||||
const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries()));
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
suppressionData,
|
||||
plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json')
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save suppression list: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load suppression list from disk
|
||||
*/
|
||||
private loadSuppressionList(): void {
|
||||
try {
|
||||
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
|
||||
|
||||
if (plugins.fs.existsSync(suppressionPath)) {
|
||||
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
||||
const entries = JSON.parse(data);
|
||||
|
||||
this.suppressionList = new Map(entries);
|
||||
|
||||
// Clean expired entries
|
||||
const now = Date.now();
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [email, info] of this.suppressionList.entries()) {
|
||||
if (info.expiresAt && now > info.expiresAt) {
|
||||
this.suppressionList.delete(email);
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredCount > 0) {
|
||||
logger.log('info', `Cleaned ${expiredCount} expired entries from suppression list`);
|
||||
this.saveSuppressionList();
|
||||
}
|
||||
|
||||
logger.log('info', `Loaded ${this.suppressionList.size} entries from suppression list`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load suppression list: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save bounce record to disk
|
||||
* @param bounce Bounce record to save
|
||||
*/
|
||||
private saveBounceRecord(bounce: BounceRecord): void {
|
||||
try {
|
||||
const bounceData = JSON.stringify(bounce);
|
||||
const bouncePath = plugins.path.join(
|
||||
paths.dataDir,
|
||||
'emails',
|
||||
'bounces',
|
||||
`${bounce.id}.json`
|
||||
);
|
||||
|
||||
// Ensure directory exists
|
||||
const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces');
|
||||
plugins.smartfile.fs.ensureDirSync(bounceDir);
|
||||
|
||||
plugins.smartfile.memory.toFsSync(bounceData, bouncePath);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save bounce record: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bounce cache with new bounce information
|
||||
* @param bounce Bounce record to update cache with
|
||||
*/
|
||||
private updateBounceCache(bounce: BounceRecord): void {
|
||||
const email = bounce.recipient.toLowerCase();
|
||||
const existing = this.bounceCache.get(email);
|
||||
|
||||
if (existing) {
|
||||
// Update existing cache entry
|
||||
existing.lastBounce = bounce.timestamp;
|
||||
existing.count++;
|
||||
existing.type = bounce.bounceType;
|
||||
existing.category = bounce.bounceCategory;
|
||||
} else {
|
||||
// Create new cache entry
|
||||
this.bounceCache.set(email, {
|
||||
lastBounce: bounce.timestamp,
|
||||
count: 1,
|
||||
type: bounce.bounceType,
|
||||
category: bounce.bounceCategory
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check bounce history for an email address
|
||||
* @param email Email address to check
|
||||
* @returns Bounce information or null if no bounces
|
||||
*/
|
||||
public getBounceInfo(email: string): {
|
||||
lastBounce: number;
|
||||
count: number;
|
||||
type: BounceType;
|
||||
category: BounceCategory;
|
||||
} | null {
|
||||
return this.bounceCache.get(email.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze SMTP response and diagnostic codes to determine bounce type
|
||||
* @param smtpResponse SMTP response string
|
||||
* @param diagnosticCode Diagnostic code from bounce
|
||||
* @param statusCode Status code from bounce
|
||||
* @returns Detected bounce type and category
|
||||
*/
|
||||
private detectBounceType(
|
||||
smtpResponse: string,
|
||||
diagnosticCode: string,
|
||||
statusCode: string
|
||||
): {
|
||||
type: BounceType;
|
||||
category: BounceCategory;
|
||||
} {
|
||||
// Combine all text for comprehensive pattern matching
|
||||
const fullText = `${smtpResponse} ${diagnosticCode} ${statusCode}`.toLowerCase();
|
||||
|
||||
// Check for auto-responses first
|
||||
if (this.matchesPattern(fullText, BounceType.AUTO_RESPONSE) ||
|
||||
this.matchesPattern(fullText, BounceType.CHALLENGE_RESPONSE)) {
|
||||
return {
|
||||
type: BounceType.AUTO_RESPONSE,
|
||||
category: BounceCategory.AUTO_RESPONSE
|
||||
};
|
||||
}
|
||||
|
||||
// Check for hard bounces
|
||||
for (const bounceType of [
|
||||
BounceType.INVALID_RECIPIENT,
|
||||
BounceType.DOMAIN_NOT_FOUND,
|
||||
BounceType.MAILBOX_FULL,
|
||||
BounceType.MAILBOX_INACTIVE,
|
||||
BounceType.BLOCKED,
|
||||
BounceType.SPAM_RELATED,
|
||||
BounceType.POLICY_RELATED
|
||||
]) {
|
||||
if (this.matchesPattern(fullText, bounceType)) {
|
||||
return {
|
||||
type: bounceType,
|
||||
category: BounceCategory.HARD
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for soft bounces
|
||||
for (const bounceType of [
|
||||
BounceType.SERVER_UNAVAILABLE,
|
||||
BounceType.TEMPORARY_FAILURE,
|
||||
BounceType.QUOTA_EXCEEDED,
|
||||
BounceType.NETWORK_ERROR,
|
||||
BounceType.TIMEOUT
|
||||
]) {
|
||||
if (this.matchesPattern(fullText, bounceType)) {
|
||||
return {
|
||||
type: bounceType,
|
||||
category: BounceCategory.SOFT
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DSN (Delivery Status Notification) status codes
|
||||
if (statusCode) {
|
||||
// Format: class.subject.detail
|
||||
const parts = statusCode.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const statusClass = parts[0];
|
||||
const statusSubject = parts[1];
|
||||
|
||||
// 5.X.X is permanent failure (hard bounce)
|
||||
if (statusClass === '5') {
|
||||
// Try to determine specific type based on subject
|
||||
if (statusSubject === '1') {
|
||||
return { type: BounceType.INVALID_RECIPIENT, category: BounceCategory.HARD };
|
||||
} else if (statusSubject === '2') {
|
||||
return { type: BounceType.MAILBOX_FULL, category: BounceCategory.HARD };
|
||||
} else if (statusSubject === '7') {
|
||||
return { type: BounceType.BLOCKED, category: BounceCategory.HARD };
|
||||
} else {
|
||||
return { type: BounceType.UNKNOWN, category: BounceCategory.HARD };
|
||||
}
|
||||
}
|
||||
|
||||
// 4.X.X is temporary failure (soft bounce)
|
||||
if (statusClass === '4') {
|
||||
// Try to determine specific type based on subject
|
||||
if (statusSubject === '2') {
|
||||
return { type: BounceType.QUOTA_EXCEEDED, category: BounceCategory.SOFT };
|
||||
} else if (statusSubject === '3') {
|
||||
return { type: BounceType.SERVER_UNAVAILABLE, category: BounceCategory.SOFT };
|
||||
} else if (statusSubject === '4') {
|
||||
return { type: BounceType.NETWORK_ERROR, category: BounceCategory.SOFT };
|
||||
} else {
|
||||
return { type: BounceType.TEMPORARY_FAILURE, category: BounceCategory.SOFT };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to unknown
|
||||
return {
|
||||
type: BounceType.UNKNOWN,
|
||||
category: BounceCategory.UNKNOWN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text matches any pattern for a bounce type
|
||||
* @param text Text to check against patterns
|
||||
* @param bounceType Bounce type to get patterns for
|
||||
* @returns Whether the text matches any pattern
|
||||
*/
|
||||
private matchesPattern(text: string, bounceType: BounceType): boolean {
|
||||
const patterns = BOUNCE_PATTERNS[bounceType];
|
||||
|
||||
if (!patterns) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known hard bounced addresses
|
||||
* @returns Array of hard bounced email addresses
|
||||
*/
|
||||
public getHardBouncedAddresses(): string[] {
|
||||
const hardBounced: string[] = [];
|
||||
|
||||
for (const [email, info] of this.bounceCache.entries()) {
|
||||
if (info.category === BounceCategory.HARD) {
|
||||
hardBounced.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
return hardBounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suppression list
|
||||
* @returns Array of suppressed email addresses
|
||||
*/
|
||||
public getSuppressionList(): string[] {
|
||||
return Array.from(this.suppressionList.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old bounce records (for maintenance)
|
||||
* @param olderThan Timestamp to remove records older than
|
||||
* @returns Number of records removed
|
||||
*/
|
||||
public clearOldBounceRecords(olderThan: number): number {
|
||||
let removed = 0;
|
||||
|
||||
this.bounceStore = this.bounceStore.filter(bounce => {
|
||||
if (bounce.timestamp < olderThan) {
|
||||
removed++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EmailService } from './classes.emailservice.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// Import MTA classes
|
||||
import {
|
||||
MtaService,
|
||||
Email as MtaEmail,
|
||||
type IEmailOptions,
|
||||
DeliveryStatus,
|
||||
type IAttachment
|
||||
} from '../mta/index.js';
|
||||
|
||||
export class MtaConnector {
|
||||
public emailRef: EmailService;
|
||||
private mtaService: MtaService;
|
||||
|
||||
constructor(emailRefArg: EmailService, mtaService?: MtaService) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.mtaService = mtaService || this.emailRef.mtaService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using the MTA service
|
||||
* @param smartmail The email to send
|
||||
* @param toAddresses Recipients (comma-separated or array)
|
||||
* @param options Additional options
|
||||
*/
|
||||
public async sendEmail(
|
||||
smartmail: plugins.smartmail.Smartmail<any>,
|
||||
toAddresses: string | string[],
|
||||
options: any = {}
|
||||
): Promise<string> {
|
||||
// Check if recipients are on the suppression list
|
||||
const recipients = Array.isArray(toAddresses)
|
||||
? toAddresses
|
||||
: toAddresses.split(',').map(addr => addr.trim());
|
||||
|
||||
// Filter out suppressed recipients
|
||||
const validRecipients = [];
|
||||
const suppressedRecipients = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (this.emailRef.bounceManager.isEmailSuppressed(recipient)) {
|
||||
suppressedRecipients.push(recipient);
|
||||
} else {
|
||||
validRecipients.push(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
// Log suppressed recipients
|
||||
if (suppressedRecipients.length > 0) {
|
||||
logger.log('warn', `Skipping ${suppressedRecipients.length} suppressed recipients`, {
|
||||
suppressedRecipients
|
||||
});
|
||||
}
|
||||
|
||||
// If all recipients are suppressed, throw error
|
||||
if (validRecipients.length === 0) {
|
||||
throw new Error('All recipients are on the suppression list');
|
||||
}
|
||||
|
||||
// Continue with valid recipients
|
||||
try {
|
||||
// Use filtered recipients - already an array, no need for toArray
|
||||
|
||||
// Add recipients to smartmail if they're not already added
|
||||
if (!smartmail.options.to || smartmail.options.to.length === 0) {
|
||||
for (const recipient of validRecipients) {
|
||||
smartmail.addRecipient(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle options
|
||||
const emailOptions: Record<string, any> = { ...options };
|
||||
|
||||
// Check if we should use MIME format
|
||||
const useMimeFormat = options.useMimeFormat ?? true;
|
||||
|
||||
if (useMimeFormat) {
|
||||
// Use smartmail's MIME conversion for improved handling
|
||||
try {
|
||||
// Convert to MIME format
|
||||
const mimeEmail = await smartmail.toMimeFormat(smartmail.options.creationObjectRef);
|
||||
|
||||
// Parse the MIME email to create an MTA Email
|
||||
return this.sendMimeEmail(mimeEmail, validRecipients);
|
||||
} catch (mimeError) {
|
||||
logger.log('warn', `Failed to use MIME format, falling back to direct conversion: ${mimeError.message}`);
|
||||
// Fall back to direct conversion
|
||||
return this.sendDirectEmail(smartmail, validRecipients);
|
||||
}
|
||||
} else {
|
||||
// Use direct conversion
|
||||
return this.sendDirectEmail(smartmail, validRecipients);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
|
||||
eventType: 'emailError',
|
||||
provider: 'mta',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
// Check if this is a bounce-related error
|
||||
if (error.message.includes('550') || // Rejected
|
||||
error.message.includes('551') || // User not local
|
||||
error.message.includes('552') || // Mailbox full
|
||||
error.message.includes('553') || // Bad mailbox name
|
||||
error.message.includes('554') || // Transaction failed
|
||||
error.message.includes('does not exist') ||
|
||||
error.message.includes('unknown user') ||
|
||||
error.message.includes('invalid recipient')) {
|
||||
|
||||
// Process as a bounce
|
||||
for (const recipient of validRecipients) {
|
||||
await this.emailRef.bounceManager.processSmtpFailure(
|
||||
recipient,
|
||||
error.message,
|
||||
{
|
||||
sender: smartmail.options.from,
|
||||
statusCode: error.message.match(/\b([45]\d{2})\b/) ? error.message.match(/\b([45]\d{2})\b/)[1] : undefined
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a MIME-formatted email
|
||||
* @param mimeEmail The MIME-formatted email content
|
||||
* @param recipients The email recipients
|
||||
*/
|
||||
private async sendMimeEmail(mimeEmail: string, recipients: string[]): Promise<string> {
|
||||
try {
|
||||
// Parse the MIME email
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(mimeEmail);
|
||||
|
||||
// Extract necessary information for MTA Email
|
||||
const mtaEmail = new MtaEmail({
|
||||
from: parsedEmail.from?.text || '',
|
||||
to: recipients,
|
||||
subject: parsedEmail.subject || '',
|
||||
text: parsedEmail.text || '',
|
||||
html: parsedEmail.html || undefined,
|
||||
attachments: parsedEmail.attachments?.map(attachment => ({
|
||||
filename: attachment.filename || 'attachment',
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType || 'application/octet-stream',
|
||||
contentId: attachment.contentId
|
||||
})) || [],
|
||||
headers: Object.fromEntries([...parsedEmail.headers].map(([key, value]) => [key, String(value)]))
|
||||
});
|
||||
|
||||
// Send using MTA
|
||||
const emailId = await this.mtaService.send(mtaEmail);
|
||||
|
||||
logger.log('info', `MIME email sent via MTA to ${recipients.join(', ')}`, {
|
||||
eventType: 'sentEmail',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
to: recipients
|
||||
});
|
||||
|
||||
return emailId;
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send MIME email: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using direct conversion (fallback method)
|
||||
* @param smartmail The Smartmail instance
|
||||
* @param recipients The email recipients
|
||||
*/
|
||||
private async sendDirectEmail(
|
||||
smartmail: plugins.smartmail.Smartmail<any>,
|
||||
recipients: string[]
|
||||
): Promise<string> {
|
||||
// Map SmartMail attachments to MTA attachments with improved content type handling
|
||||
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
|
||||
// Try to determine content type from file extension if not explicitly set
|
||||
let contentType = (attachment as any)?.contentType;
|
||||
|
||||
if (!contentType) {
|
||||
const extension = attachment.parsedPath.ext.toLowerCase();
|
||||
contentType = this.getContentTypeFromExtension(extension);
|
||||
}
|
||||
|
||||
return {
|
||||
filename: attachment.parsedPath.base,
|
||||
content: Buffer.from(attachment.contentBuffer),
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
// Add content ID for inline images if available
|
||||
contentId: (attachment as any)?.contentId
|
||||
};
|
||||
});
|
||||
|
||||
// Create MTA Email
|
||||
const mtaEmail = new MtaEmail({
|
||||
from: smartmail.options.from,
|
||||
to: recipients,
|
||||
subject: smartmail.getSubject(),
|
||||
text: smartmail.getBody(false), // Plain text version
|
||||
html: smartmail.getBody(true), // HTML version
|
||||
attachments
|
||||
});
|
||||
|
||||
// Prepare arrays for CC and BCC recipients
|
||||
let ccRecipients: string[] = [];
|
||||
let bccRecipients: string[] = [];
|
||||
|
||||
// Add CC recipients if present
|
||||
if (smartmail.options.cc?.length > 0) {
|
||||
// Handle CC recipients - smartmail options may contain email objects
|
||||
ccRecipients = smartmail.options.cc.map(r => {
|
||||
if (typeof r === 'string') return r;
|
||||
return typeof (r as any).address === 'string' ? (r as any).address :
|
||||
typeof (r as any).email === 'string' ? (r as any).email : '';
|
||||
});
|
||||
mtaEmail.cc = ccRecipients;
|
||||
}
|
||||
|
||||
// Add BCC recipients if present
|
||||
if (smartmail.options.bcc?.length > 0) {
|
||||
// Handle BCC recipients - smartmail options may contain email objects
|
||||
bccRecipients = smartmail.options.bcc.map(r => {
|
||||
if (typeof r === 'string') return r;
|
||||
return typeof (r as any).address === 'string' ? (r as any).address :
|
||||
typeof (r as any).email === 'string' ? (r as any).email : '';
|
||||
});
|
||||
mtaEmail.bcc = bccRecipients;
|
||||
}
|
||||
|
||||
// Send using MTA
|
||||
const emailId = await this.mtaService.send(mtaEmail);
|
||||
|
||||
logger.log('info', `Email sent via MTA to ${recipients.join(', ')}`, {
|
||||
eventType: 'sentEmail',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
to: recipients
|
||||
});
|
||||
|
||||
return emailId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type from file extension
|
||||
* @param extension The file extension (with or without dot)
|
||||
* @returns The content type or undefined if unknown
|
||||
*/
|
||||
private getContentTypeFromExtension(extension: string): string | undefined {
|
||||
// Remove dot if present
|
||||
const ext = extension.startsWith('.') ? extension.substring(1) : extension;
|
||||
|
||||
// Common content types
|
||||
const contentTypes: Record<string, string> = {
|
||||
'pdf': 'application/pdf',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'webp': 'image/webp',
|
||||
'txt': 'text/plain',
|
||||
'html': 'text/html',
|
||||
'csv': 'text/csv',
|
||||
'json': 'application/json',
|
||||
'xml': 'application/xml',
|
||||
'zip': 'application/zip',
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'ppt': 'application/vnd.ms-powerpoint',
|
||||
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
};
|
||||
|
||||
return contentTypes[ext.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and process an incoming email
|
||||
* For MTA, this would handle an email already received by the SMTP server
|
||||
* @param emailData The raw email data or identifier
|
||||
* @param options Additional processing options
|
||||
*/
|
||||
public async receiveEmail(
|
||||
emailData: string,
|
||||
options: {
|
||||
preserveHeaders?: boolean;
|
||||
includeRawData?: boolean;
|
||||
validateSender?: boolean;
|
||||
} = {}
|
||||
): Promise<plugins.smartmail.Smartmail<any>> {
|
||||
try {
|
||||
// In a real implementation, this would retrieve an email from the MTA storage
|
||||
// For now, we can use a simplified approach:
|
||||
|
||||
// Parse the email (assuming emailData is a raw email or a file path)
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
||||
|
||||
// Extract sender information
|
||||
const sender = parsedEmail.from?.text || '';
|
||||
let senderName = '';
|
||||
let senderEmail = sender;
|
||||
|
||||
// Try to extract name and email from "Name <email>" format
|
||||
const senderMatch = sender.match(/(.*?)\s*<([^>]+)>/);
|
||||
if (senderMatch) {
|
||||
senderName = senderMatch[1].trim();
|
||||
senderEmail = senderMatch[2].trim();
|
||||
}
|
||||
|
||||
// Extract recipients
|
||||
const recipients = [];
|
||||
if (parsedEmail.to) {
|
||||
// Extract recipients safely
|
||||
try {
|
||||
// Handle AddressObject or AddressObject[]
|
||||
if (parsedEmail.to && typeof parsedEmail.to === 'object' && 'value' in parsedEmail.to) {
|
||||
const addressList = Array.isArray(parsedEmail.to.value)
|
||||
? parsedEmail.to.value
|
||||
: [parsedEmail.to.value];
|
||||
|
||||
for (const addr of addressList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
recipients.push({
|
||||
name: addr.name || '',
|
||||
email: addr.address || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let toStr = '';
|
||||
if (parsedEmail.to && typeof parsedEmail.to === 'object' && 'text' in parsedEmail.to) {
|
||||
toStr = String(parsedEmail.to.text || '');
|
||||
}
|
||||
if (toStr) {
|
||||
recipients.push({
|
||||
name: '',
|
||||
email: toStr
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a more comprehensive creation object reference
|
||||
const creationObjectRef: Record<string, any> = {
|
||||
sender: {
|
||||
name: senderName,
|
||||
email: senderEmail
|
||||
},
|
||||
recipients: recipients,
|
||||
subject: parsedEmail.subject || '',
|
||||
date: parsedEmail.date || new Date(),
|
||||
messageId: parsedEmail.messageId || '',
|
||||
inReplyTo: parsedEmail.inReplyTo || null,
|
||||
references: parsedEmail.references || []
|
||||
};
|
||||
|
||||
// Include headers if requested
|
||||
if (options.preserveHeaders) {
|
||||
creationObjectRef.headers = parsedEmail.headers;
|
||||
}
|
||||
|
||||
// Include raw data if requested
|
||||
if (options.includeRawData) {
|
||||
creationObjectRef.rawData = emailData;
|
||||
}
|
||||
|
||||
// Create a Smartmail from the parsed email
|
||||
const smartmail = new plugins.smartmail.Smartmail({
|
||||
from: senderEmail,
|
||||
subject: parsedEmail.subject || '',
|
||||
body: parsedEmail.html || parsedEmail.text || '',
|
||||
creationObjectRef
|
||||
});
|
||||
|
||||
// Add recipients
|
||||
if (recipients.length > 0) {
|
||||
for (const recipient of recipients) {
|
||||
smartmail.addRecipient(recipient.email);
|
||||
}
|
||||
}
|
||||
|
||||
// Add CC recipients if present
|
||||
if (parsedEmail.cc) {
|
||||
try {
|
||||
// Extract CC recipients safely
|
||||
if (parsedEmail.cc && typeof parsedEmail.cc === 'object' && 'value' in parsedEmail.cc) {
|
||||
const ccList = Array.isArray(parsedEmail.cc.value)
|
||||
? parsedEmail.cc.value
|
||||
: [parsedEmail.cc.value];
|
||||
|
||||
for (const addr of ccList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
smartmail.addRecipient(addr.address, 'cc');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let ccStr = '';
|
||||
if (parsedEmail.cc && typeof parsedEmail.cc === 'object' && 'text' in parsedEmail.cc) {
|
||||
ccStr = String(parsedEmail.cc.text || '');
|
||||
}
|
||||
if (ccStr) {
|
||||
smartmail.addRecipient(ccStr, 'cc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add BCC recipients if present (usually not in received emails, but just in case)
|
||||
if (parsedEmail.bcc) {
|
||||
try {
|
||||
// Extract BCC recipients safely
|
||||
if (parsedEmail.bcc && typeof parsedEmail.bcc === 'object' && 'value' in parsedEmail.bcc) {
|
||||
const bccList = Array.isArray(parsedEmail.bcc.value)
|
||||
? parsedEmail.bcc.value
|
||||
: [parsedEmail.bcc.value];
|
||||
|
||||
for (const addr of bccList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
smartmail.addRecipient(addr.address, 'bcc');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let bccStr = '';
|
||||
if (parsedEmail.bcc && typeof parsedEmail.bcc === 'object' && 'text' in parsedEmail.bcc) {
|
||||
bccStr = String(parsedEmail.bcc.text || '');
|
||||
}
|
||||
if (bccStr) {
|
||||
smartmail.addRecipient(bccStr, 'bcc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add attachments if present
|
||||
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
|
||||
for (const attachment of parsedEmail.attachments) {
|
||||
// Create smartfile with proper constructor options
|
||||
const file = new plugins.smartfile.SmartFile({
|
||||
path: attachment.filename || 'attachment',
|
||||
contentBuffer: attachment.content,
|
||||
base: ''
|
||||
});
|
||||
|
||||
// Set content type and content ID for proper MIME handling
|
||||
if (attachment.contentType) {
|
||||
(file as any).contentType = attachment.contentType;
|
||||
}
|
||||
|
||||
if (attachment.contentId) {
|
||||
(file as any).contentId = attachment.contentId;
|
||||
}
|
||||
|
||||
smartmail.addAttachment(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sender if requested
|
||||
if (options.validateSender && this.emailRef.emailValidator) {
|
||||
try {
|
||||
const validationResult = await this.emailRef.emailValidator.validate(senderEmail, {
|
||||
checkSyntaxOnly: true // Use syntax-only for performance
|
||||
});
|
||||
|
||||
// Add validation info to the creation object
|
||||
creationObjectRef.senderValidation = validationResult;
|
||||
} catch (validationError) {
|
||||
logger.log('warn', `Sender validation error: ${validationError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return smartmail;
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to receive email via MTA: ${error.message}`, {
|
||||
eventType: 'emailError',
|
||||
provider: 'mta',
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status of a sent email
|
||||
* @param emailId The email ID to check
|
||||
*/
|
||||
public async checkEmailStatus(emailId: string): Promise<{
|
||||
status: string;
|
||||
details?: any;
|
||||
}> {
|
||||
try {
|
||||
const status = this.mtaService.getEmailStatus(emailId);
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
status: 'unknown',
|
||||
details: { message: 'Email not found' }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: status.status,
|
||||
details: {
|
||||
attempts: status.attempts,
|
||||
lastAttempt: status.lastAttempt,
|
||||
nextAttempt: status.nextAttempt,
|
||||
error: status.error?.message
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to check email status: ${error.message}`, {
|
||||
eventType: 'emailError',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
details: { message: error.message }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { MtaConnector } from './classes.connector.mta.js';
|
||||
import { RuleManager } from './classes.rulemanager.js';
|
||||
import { ApiManager } from './classes.apimanager.js';
|
||||
import { TemplateManager } from './classes.templatemanager.js';
|
||||
import { EmailValidator } from './classes.emailvalidator.js';
|
||||
import { BounceManager } from './classes.bouncemanager.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { SzPlatformService } from '../platformservice.js';
|
||||
|
||||
// Import MTA service
|
||||
import { MtaService, type IMtaConfig } from '../mta/index.js';
|
||||
|
||||
export interface IEmailConstructorOptions {
|
||||
useMta?: boolean;
|
||||
mtaConfig?: IMtaConfig;
|
||||
templateConfig?: {
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
footerHtml?: string;
|
||||
footerText?: string;
|
||||
};
|
||||
loadTemplatesFromDir?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email service with support for both Mailgun and local MTA
|
||||
*/
|
||||
export class EmailService {
|
||||
public platformServiceRef: SzPlatformService;
|
||||
|
||||
// typedrouter
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// connectors
|
||||
public mtaConnector: MtaConnector;
|
||||
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
|
||||
// MTA service
|
||||
public mtaService: MtaService;
|
||||
|
||||
// services
|
||||
public apiManager: ApiManager;
|
||||
public ruleManager: RuleManager;
|
||||
public templateManager: TemplateManager;
|
||||
public emailValidator: EmailValidator;
|
||||
public bounceManager: BounceManager;
|
||||
|
||||
// configuration
|
||||
private config: IEmailConstructorOptions;
|
||||
|
||||
constructor(platformServiceRefArg: SzPlatformService, options: IEmailConstructorOptions = {}) {
|
||||
this.platformServiceRef = platformServiceRefArg;
|
||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
|
||||
// Set default options
|
||||
this.config = {
|
||||
useMta: options.useMta ?? true,
|
||||
mtaConfig: options.mtaConfig || {},
|
||||
templateConfig: options.templateConfig || {},
|
||||
loadTemplatesFromDir: options.loadTemplatesFromDir ?? true
|
||||
};
|
||||
|
||||
// Initialize validator
|
||||
this.emailValidator = new EmailValidator();
|
||||
|
||||
// Initialize bounce manager
|
||||
this.bounceManager = new BounceManager();
|
||||
|
||||
// Initialize template manager
|
||||
this.templateManager = new TemplateManager(this.config.templateConfig);
|
||||
|
||||
if (this.config.useMta) {
|
||||
// Initialize MTA service
|
||||
this.mtaService = new MtaService(platformServiceRefArg, this.config.mtaConfig);
|
||||
// Initialize MTA connector
|
||||
this.mtaConnector = new MtaConnector(this);
|
||||
}
|
||||
|
||||
// Initialize API manager and rule manager
|
||||
this.apiManager = new ApiManager(this);
|
||||
this.ruleManager = new RuleManager(this);
|
||||
|
||||
// Set up MTA SMTP server webhook if using MTA
|
||||
if (this.config.useMta) {
|
||||
// The MTA SMTP server will handle incoming emails directly
|
||||
// through its SMTP protocol. No additional webhook needed.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the email service
|
||||
*/
|
||||
public async start() {
|
||||
// Initialize rule manager
|
||||
await this.ruleManager.init();
|
||||
|
||||
// Load email templates if configured
|
||||
if (this.config.loadTemplatesFromDir) {
|
||||
try {
|
||||
await this.templateManager.loadTemplatesFromDirectory(paths.emailTemplatesDir);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load email templates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start MTA service if enabled
|
||||
if (this.config.useMta && this.mtaService) {
|
||||
await this.mtaService.start();
|
||||
logger.log('success', 'Started MTA service');
|
||||
}
|
||||
|
||||
logger.log('success', `Started email service`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the email service
|
||||
*/
|
||||
public async stop() {
|
||||
// Stop MTA service if it's running
|
||||
if (this.config.useMta && this.mtaService) {
|
||||
await this.mtaService.stop();
|
||||
logger.log('info', 'Stopped MTA service');
|
||||
}
|
||||
|
||||
logger.log('info', 'Stopped email service');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using the configured provider (Mailgun or MTA)
|
||||
* @param email The email to send
|
||||
* @param to Recipient(s)
|
||||
* @param options Additional options
|
||||
*/
|
||||
public async sendEmail(
|
||||
email: plugins.smartmail.Smartmail<any>,
|
||||
to: string | string[],
|
||||
options: any = {}
|
||||
): Promise<string> {
|
||||
// Determine which connector to use
|
||||
if (this.config.useMta && this.mtaConnector) {
|
||||
return this.mtaConnector.sendEmail(email, to, options);
|
||||
} else {
|
||||
throw new Error('No email provider configured');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using a template
|
||||
* @param templateId The template ID
|
||||
* @param to Recipient email(s)
|
||||
* @param context The template context data
|
||||
* @param options Additional options
|
||||
*/
|
||||
public async sendTemplateEmail(
|
||||
templateId: string,
|
||||
to: string | string[],
|
||||
context: any = {},
|
||||
options: any = {}
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Get email from template
|
||||
const smartmail = await this.templateManager.prepareEmail(templateId, context);
|
||||
|
||||
// Send the email
|
||||
return this.sendEmail(smartmail, to, options);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send template email: ${error.message}`, {
|
||||
templateId,
|
||||
to,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an email address
|
||||
* @param email The email address to validate
|
||||
* @param options Validation options
|
||||
* @returns Validation result
|
||||
*/
|
||||
public async validateEmail(
|
||||
email: string,
|
||||
options: {
|
||||
checkMx?: boolean;
|
||||
checkDisposable?: boolean;
|
||||
checkRole?: boolean;
|
||||
} = {}
|
||||
): Promise<any> {
|
||||
return this.emailValidator.validate(email, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email service statistics
|
||||
*/
|
||||
public getStats() {
|
||||
const stats: any = {
|
||||
activeProviders: []
|
||||
};
|
||||
|
||||
if (this.config.useMta) {
|
||||
stats.activeProviders.push('mta');
|
||||
stats.mta = this.mtaService.getStats();
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
export interface IEmailValidationResult {
|
||||
isValid: boolean;
|
||||
hasMx: boolean;
|
||||
hasSpamMarkings: boolean;
|
||||
score: number;
|
||||
details?: {
|
||||
formatValid?: boolean;
|
||||
mxRecords?: string[];
|
||||
disposable?: boolean;
|
||||
role?: boolean;
|
||||
spamIndicators?: string[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced email validator class using smartmail's capabilities
|
||||
*/
|
||||
export class EmailValidator {
|
||||
private validator: plugins.smartmail.EmailAddressValidator;
|
||||
private dnsCache: LRUCache<string, string[]>;
|
||||
|
||||
constructor(options?: {
|
||||
maxCacheSize?: number;
|
||||
cacheTTL?: number;
|
||||
}) {
|
||||
this.validator = new plugins.smartmail.EmailAddressValidator();
|
||||
|
||||
// Initialize LRU cache for DNS records
|
||||
this.dnsCache = new LRUCache<string, string[]>({
|
||||
// Default to 1000 entries (reasonable for most applications)
|
||||
max: options?.maxCacheSize || 1000,
|
||||
// Default TTL of 1 hour (DNS records don't change frequently)
|
||||
ttl: options?.cacheTTL || 60 * 60 * 1000,
|
||||
// Optional cache monitoring
|
||||
allowStale: false,
|
||||
updateAgeOnGet: true,
|
||||
// Add logging for cache events in production environments
|
||||
disposeAfter: (value, key) => {
|
||||
logger.log('debug', `DNS cache entry expired for domain: ${key}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address using comprehensive checks
|
||||
* @param email The email to validate
|
||||
* @param options Validation options
|
||||
* @returns Validation result with details
|
||||
*/
|
||||
public async validate(
|
||||
email: string,
|
||||
options: {
|
||||
checkMx?: boolean;
|
||||
checkDisposable?: boolean;
|
||||
checkRole?: boolean;
|
||||
checkSyntaxOnly?: boolean;
|
||||
} = {}
|
||||
): Promise<IEmailValidationResult> {
|
||||
try {
|
||||
const result: IEmailValidationResult = {
|
||||
isValid: false,
|
||||
hasMx: false,
|
||||
hasSpamMarkings: false,
|
||||
score: 0,
|
||||
details: {
|
||||
formatValid: false,
|
||||
spamIndicators: []
|
||||
}
|
||||
};
|
||||
|
||||
// Always check basic format
|
||||
result.details.formatValid = this.validator.isValidEmailFormat(email);
|
||||
if (!result.details.formatValid) {
|
||||
result.details.errorMessage = 'Invalid email format';
|
||||
return result;
|
||||
}
|
||||
|
||||
// If syntax-only check is requested, return early
|
||||
if (options.checkSyntaxOnly) {
|
||||
result.isValid = true;
|
||||
result.score = 0.5;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get domain for additional checks
|
||||
const domain = email.split('@')[1];
|
||||
|
||||
// Check MX records
|
||||
if (options.checkMx !== false) {
|
||||
try {
|
||||
const mxRecords = await this.getMxRecords(domain);
|
||||
result.details.mxRecords = mxRecords;
|
||||
result.hasMx = mxRecords && mxRecords.length > 0;
|
||||
|
||||
if (!result.hasMx) {
|
||||
result.details.spamIndicators.push('No MX records');
|
||||
result.details.errorMessage = 'Domain has no MX records';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking MX records: ${error.message}`);
|
||||
result.details.errorMessage = 'Unable to check MX records';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if domain is disposable
|
||||
if (options.checkDisposable !== false) {
|
||||
result.details.disposable = await this.validator.isDisposableEmail(email);
|
||||
if (result.details.disposable) {
|
||||
result.details.spamIndicators.push('Disposable email');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if email is a role account
|
||||
if (options.checkRole !== false) {
|
||||
result.details.role = this.validator.isRoleAccount(email);
|
||||
if (result.details.role) {
|
||||
result.details.spamIndicators.push('Role account');
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate spam score and final validity
|
||||
result.hasSpamMarkings = result.details.spamIndicators.length > 0;
|
||||
|
||||
// Calculate a score between 0-1 based on checks
|
||||
let scoreFactors = 0;
|
||||
let scoreTotal = 0;
|
||||
|
||||
// Format check (highest weight)
|
||||
scoreFactors += 0.4;
|
||||
if (result.details.formatValid) scoreTotal += 0.4;
|
||||
|
||||
// MX check (high weight)
|
||||
if (options.checkMx !== false) {
|
||||
scoreFactors += 0.3;
|
||||
if (result.hasMx) scoreTotal += 0.3;
|
||||
}
|
||||
|
||||
// Disposable check (medium weight)
|
||||
if (options.checkDisposable !== false) {
|
||||
scoreFactors += 0.2;
|
||||
if (!result.details.disposable) scoreTotal += 0.2;
|
||||
}
|
||||
|
||||
// Role account check (low weight)
|
||||
if (options.checkRole !== false) {
|
||||
scoreFactors += 0.1;
|
||||
if (!result.details.role) scoreTotal += 0.1;
|
||||
}
|
||||
|
||||
// Normalize score based on factors actually checked
|
||||
result.score = scoreFactors > 0 ? scoreTotal / scoreFactors : 0;
|
||||
|
||||
// Email is valid if score is above 0.7 (configurable threshold)
|
||||
result.isValid = result.score >= 0.7;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Email validation error: ${error.message}`);
|
||||
return {
|
||||
isValid: false,
|
||||
hasMx: false,
|
||||
hasSpamMarkings: true,
|
||||
score: 0,
|
||||
details: {
|
||||
formatValid: false,
|
||||
errorMessage: `Validation error: ${error.message}`,
|
||||
spamIndicators: ['Validation error']
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MX records for a domain with caching
|
||||
* @param domain Domain to check
|
||||
* @returns Array of MX records
|
||||
*/
|
||||
private async getMxRecords(domain: string): Promise<string[]> {
|
||||
// Check cache first
|
||||
const cachedRecords = this.dnsCache.get(domain);
|
||||
if (cachedRecords) {
|
||||
logger.log('debug', `Using cached MX records for domain: ${domain}`);
|
||||
return cachedRecords;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use smartmail's getMxRecords method
|
||||
const records = await this.validator.getMxRecords(domain);
|
||||
|
||||
// Store in cache (TTL is handled by the LRU cache configuration)
|
||||
this.dnsCache.set(domain, records);
|
||||
logger.log('debug', `Cached MX records for domain: ${domain}`);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error fetching MX records for ${domain}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates multiple email addresses in batch
|
||||
* @param emails Array of emails to validate
|
||||
* @param options Validation options
|
||||
* @returns Object with email addresses as keys and validation results as values
|
||||
*/
|
||||
public async validateBatch(
|
||||
emails: string[],
|
||||
options: {
|
||||
checkMx?: boolean;
|
||||
checkDisposable?: boolean;
|
||||
checkRole?: boolean;
|
||||
checkSyntaxOnly?: boolean;
|
||||
} = {}
|
||||
): Promise<Record<string, IEmailValidationResult>> {
|
||||
const results: Record<string, IEmailValidationResult> = {};
|
||||
|
||||
for (const email of emails) {
|
||||
results[email] = await this.validate(email, options);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if an email format is valid (synchronous, no DNS checks)
|
||||
* @param email Email to check
|
||||
* @returns Boolean indicating if format is valid
|
||||
*/
|
||||
public isValidFormat(email: string): boolean {
|
||||
return this.validator.isValidEmailFormat(email);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EmailService } from './classes.emailservice.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export class RuleManager {
|
||||
public emailRef: EmailService;
|
||||
public smartruleInstance = new plugins.smartrule.SmartRule<
|
||||
plugins.smartmail.Smartmail<any>
|
||||
>();
|
||||
|
||||
constructor(emailRefArg: EmailService) {
|
||||
this.emailRef = emailRefArg;
|
||||
|
||||
// Register MTA handler for incoming emails if MTA is enabled
|
||||
if (this.emailRef.mtaService) {
|
||||
this.setupMtaIncomingHandler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up handler for incoming emails via MTA's SMTP server
|
||||
*/
|
||||
private setupMtaIncomingHandler() {
|
||||
// The original MtaService doesn't have a direct callback for incoming emails,
|
||||
// but we can modify this approach based on how you prefer to integrate.
|
||||
// One option would be to extend the MtaService to add an event emitter.
|
||||
|
||||
// For now, we'll use a directory watcher as an example
|
||||
// This would watch the directory where MTA saves incoming emails
|
||||
const incomingDir = this.emailRef.mtaService['receivedEmailsDir'] || './received';
|
||||
|
||||
// Simple file watcher (in real implementation, use proper file watching)
|
||||
// This is just conceptual - would need modification to work with your specific setup
|
||||
this.watchIncomingEmails(incomingDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch directory for incoming emails (conceptual implementation)
|
||||
*/
|
||||
private watchIncomingEmails(directory: string) {
|
||||
console.log(`Watching for incoming emails in: ${directory}`);
|
||||
|
||||
// Conceptual - in a real implementation, set up proper file watching
|
||||
// or modify the MTA to emit events when emails are received
|
||||
|
||||
/*
|
||||
// Example using a file watcher:
|
||||
const watcher = plugins.fs.watch(directory, async (eventType, filename) => {
|
||||
if (eventType === 'rename' && filename.endsWith('.eml')) {
|
||||
const filePath = plugins.path.join(directory, filename);
|
||||
await this.handleMtaIncomingEmail(filePath);
|
||||
}
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming email received via MTA
|
||||
*/
|
||||
public async handleMtaIncomingEmail(emailPath: string) {
|
||||
try {
|
||||
// Process the email file
|
||||
const fetchedSmartmail = await this.emailRef.mtaConnector.receiveEmail(emailPath);
|
||||
|
||||
console.log('=======================');
|
||||
console.log('Received a mail via MTA:');
|
||||
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
|
||||
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
|
||||
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
|
||||
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
|
||||
{
|
||||
eventType: 'receivedEmail',
|
||||
provider: 'mta',
|
||||
email: {
|
||||
from: fetchedSmartmail.options.creationObjectRef.From,
|
||||
to: fetchedSmartmail.options.creationObjectRef.To,
|
||||
subject: fetchedSmartmail.options.creationObjectRef.Subject,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Process with rules
|
||||
this.smartruleInstance.makeDecision(fetchedSmartmail);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to process incoming MTA email: ${error.message}`, {
|
||||
eventType: 'emailError',
|
||||
provider: 'mta',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async init() {
|
||||
// Setup email rules
|
||||
await this.createForwards();
|
||||
}
|
||||
|
||||
/**
|
||||
* creates the default forwards
|
||||
*/
|
||||
public async createForwards() {
|
||||
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [];
|
||||
console.log(`${forwards.length} forward rules configured:`);
|
||||
for (const forward of forwards) {
|
||||
console.log(forward);
|
||||
}
|
||||
|
||||
for (const forward of forwards) {
|
||||
this.smartruleInstance.createRule(
|
||||
10,
|
||||
async (smartmailArg) => {
|
||||
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
|
||||
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
|
||||
}, false);
|
||||
if (matched) {
|
||||
console.log('Forward rule matched');
|
||||
console.log(forward);
|
||||
return 'apply-continue';
|
||||
} else {
|
||||
return 'continue';
|
||||
}
|
||||
},
|
||||
async (smartmailArg: plugins.smartmail.Smartmail<any>) => {
|
||||
forward.forwardedToAddress.map(async (toArg) => {
|
||||
const forwardedSmartMail = new plugins.smartmail.Smartmail({
|
||||
body:
|
||||
`
|
||||
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
|
||||
<div><b>Original Sender:</b></div>
|
||||
<div>${smartmailArg.options.creationObjectRef.From}</div>
|
||||
<div><b>Original Recipient:</b></div>
|
||||
<div>${smartmailArg.options.creationObjectRef.To}</div>
|
||||
<div><b>Forwarded to:</b></div>
|
||||
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
|
||||
return `${pVal ? pVal + ', ' : ''}${cVal}`;
|
||||
}, null)}</div>
|
||||
<div><b>Subject:</b></div>
|
||||
<div>${smartmailArg.getSubject()}</div>
|
||||
<div><b>The original body can be found below.</b></div>
|
||||
</div>
|
||||
` + smartmailArg.getBody(),
|
||||
from: 'forwarder@mail.lossless.one',
|
||||
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
|
||||
});
|
||||
for (const attachment of smartmailArg.attachments) {
|
||||
forwardedSmartMail.addAttachment(attachment);
|
||||
}
|
||||
|
||||
// Use the EmailService's sendEmail method to send with the appropriate provider
|
||||
await this.emailRef.sendEmail(forwardedSmartMail, toArg);
|
||||
|
||||
console.log(`forwarded mail to ${toArg}`);
|
||||
logger.log(
|
||||
'info',
|
||||
`email from ${
|
||||
smartmailArg.options.creationObjectRef.From
|
||||
} to ${toArg} with subject '${smartmailArg.getSubject()}'`,
|
||||
{
|
||||
eventType: 'forwardedEmail',
|
||||
email: {
|
||||
from: smartmailArg.options.creationObjectRef.From,
|
||||
to: smartmailArg.options.creationObjectRef.To,
|
||||
forwardedTo: toArg,
|
||||
subject: smartmailArg.options.creationObjectRef.Subject,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Email template type definition
|
||||
*/
|
||||
export interface IEmailTemplate<T = any> {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
from: string;
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
category?: string;
|
||||
sampleData?: T;
|
||||
attachments?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email template context - data used to render the template
|
||||
*/
|
||||
export interface ITemplateContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template category definitions
|
||||
*/
|
||||
export enum TemplateCategory {
|
||||
NOTIFICATION = 'notification',
|
||||
TRANSACTIONAL = 'transactional',
|
||||
MARKETING = 'marketing',
|
||||
SYSTEM = 'system'
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced template manager using smartmail's capabilities
|
||||
*/
|
||||
export class TemplateManager {
|
||||
private templates: Map<string, IEmailTemplate> = new Map();
|
||||
private defaultConfig: {
|
||||
from: string;
|
||||
replyTo?: string;
|
||||
footerHtml?: string;
|
||||
footerText?: string;
|
||||
};
|
||||
|
||||
constructor(defaultConfig?: {
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
footerHtml?: string;
|
||||
footerText?: string;
|
||||
}) {
|
||||
// Set default configuration
|
||||
this.defaultConfig = {
|
||||
from: defaultConfig?.from || 'noreply@mail.lossless.com',
|
||||
replyTo: defaultConfig?.replyTo,
|
||||
footerHtml: defaultConfig?.footerHtml || '',
|
||||
footerText: defaultConfig?.footerText || ''
|
||||
};
|
||||
|
||||
// Initialize with built-in templates
|
||||
this.registerBuiltinTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register built-in email templates
|
||||
*/
|
||||
private registerBuiltinTemplates(): void {
|
||||
// Welcome email
|
||||
this.registerTemplate<{
|
||||
firstName: string;
|
||||
accountUrl: string;
|
||||
}>({
|
||||
id: 'welcome',
|
||||
name: 'Welcome Email',
|
||||
description: 'Sent to users when they first sign up',
|
||||
from: this.defaultConfig.from,
|
||||
subject: 'Welcome to {{serviceName}}!',
|
||||
category: TemplateCategory.TRANSACTIONAL,
|
||||
bodyHtml: `
|
||||
<h1>Welcome, {{firstName}}!</h1>
|
||||
<p>Thank you for joining {{serviceName}}. We're excited to have you on board.</p>
|
||||
<p>To get started, <a href="{{accountUrl}}">visit your account</a>.</p>
|
||||
`,
|
||||
bodyText:
|
||||
`Welcome, {{firstName}}!
|
||||
|
||||
Thank you for joining {{serviceName}}. We're excited to have you on board.
|
||||
|
||||
To get started, visit your account: {{accountUrl}}
|
||||
`,
|
||||
sampleData: {
|
||||
firstName: 'John',
|
||||
accountUrl: 'https://example.com/account'
|
||||
}
|
||||
});
|
||||
|
||||
// Password reset
|
||||
this.registerTemplate<{
|
||||
resetUrl: string;
|
||||
expiryHours: number;
|
||||
}>({
|
||||
id: 'password-reset',
|
||||
name: 'Password Reset',
|
||||
description: 'Sent when a user requests a password reset',
|
||||
from: this.defaultConfig.from,
|
||||
subject: 'Password Reset Request',
|
||||
category: TemplateCategory.TRANSACTIONAL,
|
||||
bodyHtml: `
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>You recently requested to reset your password. Click the link below to reset it:</p>
|
||||
<p><a href="{{resetUrl}}">Reset Password</a></p>
|
||||
<p>This link will expire in {{expiryHours}} hours.</p>
|
||||
<p>If you didn't request a password reset, please ignore this email.</p>
|
||||
`,
|
||||
sampleData: {
|
||||
resetUrl: 'https://example.com/reset-password?token=abc123',
|
||||
expiryHours: 24
|
||||
}
|
||||
});
|
||||
|
||||
// System notification
|
||||
this.registerTemplate({
|
||||
id: 'system-notification',
|
||||
name: 'System Notification',
|
||||
description: 'General system notification template',
|
||||
from: this.defaultConfig.from,
|
||||
subject: '{{subject}}',
|
||||
category: TemplateCategory.SYSTEM,
|
||||
bodyHtml: `
|
||||
<h2>{{title}}</h2>
|
||||
<div>{{message}}</div>
|
||||
`,
|
||||
sampleData: {
|
||||
subject: 'Important System Notification',
|
||||
title: 'System Maintenance',
|
||||
message: 'The system will be undergoing maintenance on Saturday from 2-4am UTC.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new email template
|
||||
* @param template The email template to register
|
||||
*/
|
||||
public registerTemplate<T = any>(template: IEmailTemplate<T>): void {
|
||||
if (this.templates.has(template.id)) {
|
||||
logger.log('warn', `Template with ID '${template.id}' already exists and will be overwritten`);
|
||||
}
|
||||
|
||||
// Add footer to templates if configured
|
||||
if (this.defaultConfig.footerHtml && template.bodyHtml) {
|
||||
template.bodyHtml += this.defaultConfig.footerHtml;
|
||||
}
|
||||
|
||||
if (this.defaultConfig.footerText && template.bodyText) {
|
||||
template.bodyText += this.defaultConfig.footerText;
|
||||
}
|
||||
|
||||
this.templates.set(template.id, template);
|
||||
logger.log('info', `Registered email template: ${template.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an email template by ID
|
||||
* @param templateId The template ID
|
||||
* @returns The template or undefined if not found
|
||||
*/
|
||||
public getTemplate<T = any>(templateId: string): IEmailTemplate<T> | undefined {
|
||||
return this.templates.get(templateId) as IEmailTemplate<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available templates
|
||||
* @param category Optional category filter
|
||||
* @returns Array of email templates
|
||||
*/
|
||||
public listTemplates(category?: TemplateCategory): IEmailTemplate[] {
|
||||
const templates = Array.from(this.templates.values());
|
||||
if (category) {
|
||||
return templates.filter(template => template.category === category);
|
||||
}
|
||||
return templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Smartmail instance from a template
|
||||
* @param templateId The template ID
|
||||
* @param context The template context data
|
||||
* @returns A configured Smartmail instance
|
||||
*/
|
||||
public async createSmartmail<T = any>(
|
||||
templateId: string,
|
||||
context?: ITemplateContext
|
||||
): Promise<plugins.smartmail.Smartmail<T>> {
|
||||
const template = this.getTemplate(templateId);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`Template with ID '${templateId}' not found`);
|
||||
}
|
||||
|
||||
// Create Smartmail instance with template content
|
||||
const smartmail = new plugins.smartmail.Smartmail<T>({
|
||||
from: template.from || this.defaultConfig.from,
|
||||
subject: template.subject,
|
||||
body: template.bodyHtml || template.bodyText || '',
|
||||
creationObjectRef: context as T
|
||||
});
|
||||
|
||||
// Add any template attachments
|
||||
if (template.attachments && template.attachments.length > 0) {
|
||||
for (const attachment of template.attachments) {
|
||||
// Load attachment file
|
||||
try {
|
||||
const attachmentPath = plugins.path.isAbsolute(attachment.path)
|
||||
? attachment.path
|
||||
: plugins.path.join(paths.MtaAttachmentsDir, attachment.path);
|
||||
|
||||
// Use appropriate SmartFile method - either read from file or create with empty buffer
|
||||
// For a file path, use the fromFilePath static method
|
||||
const file = await plugins.smartfile.SmartFile.fromFilePath(attachmentPath);
|
||||
|
||||
// Set content type if specified
|
||||
if (attachment.contentType) {
|
||||
(file as any).contentType = attachment.contentType;
|
||||
}
|
||||
|
||||
smartmail.addAttachment(file);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to add attachment '${attachment.name}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply template variables if context provided
|
||||
if (context) {
|
||||
// Use applyVariables from smartmail v2.1.0+
|
||||
smartmail.applyVariables(context);
|
||||
}
|
||||
|
||||
return smartmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and completely process a Smartmail instance from a template
|
||||
* @param templateId The template ID
|
||||
* @param context The template context data
|
||||
* @returns A complete, processed Smartmail instance ready to send
|
||||
*/
|
||||
public async prepareEmail<T = any>(
|
||||
templateId: string,
|
||||
context: ITemplateContext = {}
|
||||
): Promise<plugins.smartmail.Smartmail<T>> {
|
||||
const smartmail = await this.createSmartmail<T>(templateId, context);
|
||||
|
||||
// Pre-compile all mustache templates (subject, body)
|
||||
smartmail.getSubject();
|
||||
smartmail.getBody();
|
||||
|
||||
return smartmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MIME-formatted email from a template
|
||||
* @param templateId The template ID
|
||||
* @param context The template context data
|
||||
* @returns A MIME-formatted email string
|
||||
*/
|
||||
public async createMimeEmail(
|
||||
templateId: string,
|
||||
context: ITemplateContext = {}
|
||||
): Promise<string> {
|
||||
const smartmail = await this.prepareEmail(templateId, context);
|
||||
return smartmail.toMimeFormat();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load templates from a directory
|
||||
* @param directory The directory containing template JSON files
|
||||
*/
|
||||
public async loadTemplatesFromDirectory(directory: string): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
if (!plugins.fs.existsSync(directory)) {
|
||||
logger.log('error', `Template directory does not exist: ${directory}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all JSON files
|
||||
const files = plugins.fs.readdirSync(directory)
|
||||
.filter(file => file.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = plugins.path.join(directory, file);
|
||||
const content = plugins.fs.readFileSync(filePath, 'utf8');
|
||||
const template = JSON.parse(content) as IEmailTemplate;
|
||||
|
||||
// Validate template
|
||||
if (!template.id || !template.subject || (!template.bodyHtml && !template.bodyText)) {
|
||||
logger.log('warn', `Invalid template in ${file}: missing required fields`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.registerTemplate(template);
|
||||
} catch (error) {
|
||||
logger.log('error', `Error loading template from ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Loaded ${this.templates.size} email templates`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load templates from directory: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { EmailService } from './classes.emailservice.js';
|
||||
import { BounceManager, BounceType, BounceCategory } from './classes.bouncemanager.js';
|
||||
import { EmailValidator } from './classes.emailvalidator.js';
|
||||
import { TemplateManager } from './classes.templatemanager.js';
|
||||
import { RuleManager } from './classes.rulemanager.js';
|
||||
import { ApiManager } from './classes.apimanager.js';
|
||||
import { MtaConnector } from './classes.connector.mta.js';
|
||||
|
||||
export {
|
||||
EmailService as Email,
|
||||
BounceManager,
|
||||
BounceType,
|
||||
BounceCategory,
|
||||
EmailValidator,
|
||||
TemplateManager,
|
||||
RuleManager,
|
||||
ApiManager,
|
||||
MtaConnector
|
||||
};
|
||||
525
ts/errors/base.errors.ts
Normal file
525
ts/errors/base.errors.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// Import TLogLevel from plugins
|
||||
import type { TLogLevel } from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Context information added to structured errors
|
||||
*/
|
||||
export interface IErrorContext {
|
||||
/** Component or service where the error occurred */
|
||||
component?: string;
|
||||
|
||||
/** Operation that was being performed */
|
||||
operation?: string;
|
||||
|
||||
/** Unique request ID if available */
|
||||
requestId?: string;
|
||||
|
||||
/** Error occurred at timestamp */
|
||||
timestamp?: number;
|
||||
|
||||
/** User-visible message (safe to display to end-users) */
|
||||
userMessage?: string;
|
||||
|
||||
/** Additional structured data for debugging */
|
||||
data?: Record<string, any>;
|
||||
|
||||
/** Related entity IDs if applicable */
|
||||
entity?: {
|
||||
type: string;
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
/** Stack trace (if enabled in configuration) */
|
||||
stack?: string;
|
||||
|
||||
/** Retry information if applicable */
|
||||
retry?: {
|
||||
/** Maximum number of retries allowed */
|
||||
maxRetries?: number;
|
||||
|
||||
/** Current retry count */
|
||||
currentRetry?: number;
|
||||
|
||||
/** Next retry timestamp */
|
||||
nextRetryAt?: number;
|
||||
|
||||
/** Delay between retries (in ms) */
|
||||
retryDelay?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all errors in the Platform Service
|
||||
* Adds structured error information, logging, and error tracking
|
||||
*/
|
||||
export class PlatformError extends Error {
|
||||
/** Error code identifying the specific error type */
|
||||
public readonly code: string;
|
||||
|
||||
/** Error severity level */
|
||||
public readonly severity: ErrorSeverity;
|
||||
|
||||
/** Error category for grouping related errors */
|
||||
public readonly category: ErrorCategory;
|
||||
|
||||
/** Whether the error can be recovered from automatically */
|
||||
public readonly recoverability: ErrorRecoverability;
|
||||
|
||||
/** Additional context information */
|
||||
public readonly context: IErrorContext;
|
||||
|
||||
/**
|
||||
* Creates a new PlatformError
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code from error.codes.ts
|
||||
* @param severity Error severity level
|
||||
* @param category Error category
|
||||
* @param recoverability Error recoverability indication
|
||||
* @param context Additional context information
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
severity: ErrorSeverity = ErrorSeverity.MEDIUM,
|
||||
category: ErrorCategory = ErrorCategory.OTHER,
|
||||
recoverability: ErrorRecoverability = ErrorRecoverability.NON_RECOVERABLE,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message);
|
||||
|
||||
// Set error metadata
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
this.severity = severity;
|
||||
this.category = category;
|
||||
this.recoverability = recoverability;
|
||||
|
||||
// Add timestamp if not provided
|
||||
this.context = {
|
||||
...context,
|
||||
timestamp: context.timestamp || Date.now(),
|
||||
};
|
||||
|
||||
// Capture stack trace
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
|
||||
// Log the error automatically unless explicitly disabled
|
||||
if (!context.data?.skipLogging) {
|
||||
this.logError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the error using the platform logger
|
||||
*/
|
||||
private logError(): void {
|
||||
const logLevel = this.getLogLevelFromSeverity() as TLogLevel;
|
||||
|
||||
// Construct structured log entry
|
||||
const logData = {
|
||||
error_code: this.code,
|
||||
error_name: this.name,
|
||||
severity: this.severity,
|
||||
category: this.category,
|
||||
recoverability: this.recoverability,
|
||||
...this.context
|
||||
};
|
||||
|
||||
// Log with appropriate level
|
||||
logger.log(logLevel, this.message, logData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps severity levels to log levels
|
||||
*/
|
||||
private getLogLevelFromSeverity(): string {
|
||||
switch (this.severity) {
|
||||
case ErrorSeverity.CRITICAL:
|
||||
case ErrorSeverity.HIGH:
|
||||
return 'error';
|
||||
case ErrorSeverity.MEDIUM:
|
||||
return 'warn';
|
||||
case ErrorSeverity.LOW:
|
||||
return 'info';
|
||||
case ErrorSeverity.INFO:
|
||||
return 'debug';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a JSON representation of the error
|
||||
*/
|
||||
public toJSON(): Record<string, any> {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
severity: this.severity,
|
||||
category: this.category,
|
||||
recoverability: this.recoverability,
|
||||
context: this.context,
|
||||
stack: process.env.NODE_ENV !== 'production' ? this.stack : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance with retry information
|
||||
*
|
||||
* @param maxRetries Maximum number of retries
|
||||
* @param currentRetry Current retry count
|
||||
* @param retryDelay Delay between retries in ms
|
||||
*/
|
||||
public withRetry(
|
||||
maxRetries: number,
|
||||
currentRetry: number = 0,
|
||||
retryDelay: number = 1000
|
||||
): PlatformError {
|
||||
const nextRetryAt = Date.now() + retryDelay;
|
||||
|
||||
// Clone the error with updated context
|
||||
const newContext = {
|
||||
...this.context,
|
||||
retry: {
|
||||
maxRetries,
|
||||
currentRetry,
|
||||
nextRetryAt,
|
||||
retryDelay
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new instance using the protected method that subclasses can override
|
||||
const newError = this.createWithContext(newContext);
|
||||
|
||||
// Update recoverability if we can retry
|
||||
if (currentRetry < maxRetries && newError.recoverability === ErrorRecoverability.NON_RECOVERABLE) {
|
||||
(newError as any).recoverability = ErrorRecoverability.MAYBE_RECOVERABLE;
|
||||
}
|
||||
|
||||
return newError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method to create a new instance with updated context
|
||||
* Subclasses can override this to handle their own constructor signatures
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
// Default implementation for PlatformError
|
||||
return new (this.constructor as typeof PlatformError)(
|
||||
this.message,
|
||||
this.code,
|
||||
this.severity,
|
||||
this.category,
|
||||
this.recoverability,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the error should be retried based on retry information
|
||||
*/
|
||||
public shouldRetry(): boolean {
|
||||
const { retry } = this.context;
|
||||
if (!retry) return false;
|
||||
|
||||
return retry.currentRetry < retry.maxRetries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user-friendly message that is safe to display to end users
|
||||
*/
|
||||
public getUserMessage(): string {
|
||||
return this.context.userMessage || 'An unexpected error occurred.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for validation errors
|
||||
*/
|
||||
export class ValidationError extends PlatformError {
|
||||
/**
|
||||
* Creates a new validation error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.LOW,
|
||||
ErrorCategory.VALIDATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle ValidationError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ValidationError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for configuration errors
|
||||
*/
|
||||
export class ConfigurationError extends PlatformError {
|
||||
/**
|
||||
* Creates a new configuration error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.CONFIGURATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle ConfigurationError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ConfigurationError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for network-related errors
|
||||
*/
|
||||
export class NetworkError extends PlatformError {
|
||||
/**
|
||||
* Creates a new network error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.CONNECTIVITY,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle NetworkError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof NetworkError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for resource availability errors (rate limits, quotas)
|
||||
*/
|
||||
export class ResourceError extends PlatformError {
|
||||
/**
|
||||
* Creates a new resource error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.RESOURCE,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle ResourceError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ResourceError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for authentication/authorization errors
|
||||
*/
|
||||
export class AuthenticationError extends PlatformError {
|
||||
/**
|
||||
* Creates a new authentication error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.HIGH,
|
||||
ErrorCategory.AUTHENTICATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle AuthenticationError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof AuthenticationError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for operation errors (API calls, processing)
|
||||
*/
|
||||
export class OperationError extends PlatformError {
|
||||
/**
|
||||
* Creates a new operation error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle OperationError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof OperationError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for critical system errors
|
||||
*/
|
||||
export class SystemError extends PlatformError {
|
||||
/**
|
||||
* Creates a new system error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.CRITICAL,
|
||||
ErrorCategory.OTHER,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the appropriate error class based on error category
|
||||
*
|
||||
* @param category Error category
|
||||
* @returns The appropriate error class
|
||||
*/
|
||||
export function getErrorClassForCategory(category: ErrorCategory): any {
|
||||
switch (category) {
|
||||
case ErrorCategory.VALIDATION:
|
||||
return ValidationError;
|
||||
case ErrorCategory.CONFIGURATION:
|
||||
return ConfigurationError;
|
||||
case ErrorCategory.CONNECTIVITY:
|
||||
return NetworkError;
|
||||
case ErrorCategory.RESOURCE:
|
||||
return ResourceError;
|
||||
case ErrorCategory.AUTHENTICATION:
|
||||
return AuthenticationError;
|
||||
case ErrorCategory.OPERATION:
|
||||
return OperationError;
|
||||
default:
|
||||
return PlatformError;
|
||||
}
|
||||
}
|
||||
412
ts/errors/error-handler.ts
Normal file
412
ts/errors/error-handler.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { PlatformError } from './base.errors.js';
|
||||
import type { IErrorContext } from './base.errors.js';
|
||||
import { ErrorCategory, ErrorRecoverability, ErrorSeverity } from './error.codes.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Error handler configuration
|
||||
*/
|
||||
export interface IErrorHandlerConfig {
|
||||
/** Whether to log errors automatically */
|
||||
logErrors: boolean;
|
||||
|
||||
/** Whether to include stack traces in prod environment */
|
||||
includeStacksInProd: boolean;
|
||||
|
||||
/** Default retry options */
|
||||
retry: {
|
||||
/** Maximum retry attempts */
|
||||
maxAttempts: number;
|
||||
|
||||
/** Base delay between retries in ms */
|
||||
baseDelay: number;
|
||||
|
||||
/** Maximum delay between retries in ms */
|
||||
maxDelay: number;
|
||||
|
||||
/** Backoff factor for exponential backoff */
|
||||
backoffFactor: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Global error handler configuration
|
||||
*/
|
||||
const config: IErrorHandlerConfig = {
|
||||
logErrors: true,
|
||||
includeStacksInProd: false,
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Error handler utility
|
||||
* Provides methods for consistent error handling across the platform
|
||||
*/
|
||||
export class ErrorHandler {
|
||||
/**
|
||||
* Current configuration
|
||||
*/
|
||||
public static config = config;
|
||||
|
||||
/**
|
||||
* Update error handler configuration
|
||||
*
|
||||
* @param newConfig New configuration (partial)
|
||||
*/
|
||||
public static configure(newConfig: Partial<IErrorHandlerConfig>): void {
|
||||
ErrorHandler.config = {
|
||||
...ErrorHandler.config,
|
||||
...newConfig,
|
||||
retry: {
|
||||
...ErrorHandler.config.retry,
|
||||
...(newConfig.retry || {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any error to a PlatformError
|
||||
*
|
||||
* @param error Error to convert
|
||||
* @param defaultCode Default error code if not a PlatformError
|
||||
* @param context Additional context
|
||||
* @returns PlatformError instance
|
||||
*/
|
||||
public static toPlatformError(
|
||||
error: any,
|
||||
defaultCode: string,
|
||||
context: IErrorContext = {}
|
||||
): PlatformError {
|
||||
// If already a PlatformError, just add context
|
||||
if (error instanceof PlatformError) {
|
||||
// Add context if provided
|
||||
if (Object.keys(context).length > 0) {
|
||||
return new (error.constructor as typeof PlatformError)(
|
||||
error.message,
|
||||
error.code,
|
||||
error.severity,
|
||||
error.category,
|
||||
error.recoverability,
|
||||
{
|
||||
...error.context,
|
||||
...context,
|
||||
data: {
|
||||
...(error.context.data || {}),
|
||||
...(context.data || {})
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
// Convert standard Error to PlatformError
|
||||
if (error instanceof Error) {
|
||||
return new PlatformError(
|
||||
error.message,
|
||||
defaultCode,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...(context.data || {}),
|
||||
originalError: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Not an Error instance
|
||||
return new PlatformError(
|
||||
typeof error === 'string' ? error : 'Unknown error',
|
||||
defaultCode,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an error for API responses
|
||||
* Sanitizes errors for safe external exposure
|
||||
*
|
||||
* @param error Error to format
|
||||
* @param includeDetails Whether to include detailed information
|
||||
* @returns Formatted error object
|
||||
*/
|
||||
public static formatErrorForResponse(
|
||||
error: any,
|
||||
includeDetails: boolean = false
|
||||
): Record<string, any> {
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
error,
|
||||
'PLATFORM_OPERATION_ERROR'
|
||||
);
|
||||
|
||||
// Basic error information
|
||||
const responseError: Record<string, any> = {
|
||||
code: platformError.code,
|
||||
message: platformError.getUserMessage(),
|
||||
requestId: platformError.context.requestId
|
||||
};
|
||||
|
||||
// Include more details if requested
|
||||
if (includeDetails) {
|
||||
responseError.details = {
|
||||
severity: platformError.severity,
|
||||
category: platformError.category,
|
||||
rawMessage: platformError.message,
|
||||
data: platformError.context.data
|
||||
};
|
||||
|
||||
// Only include stack trace in non-production or if explicitly enabled
|
||||
if (process.env.NODE_ENV !== 'production' || ErrorHandler.config.includeStacksInProd) {
|
||||
responseError.details.stack = platformError.stack;
|
||||
}
|
||||
}
|
||||
|
||||
return responseError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error with consistent logging and formatting
|
||||
*
|
||||
* @param error Error to handle
|
||||
* @param defaultCode Default error code if not a PlatformError
|
||||
* @param context Additional context
|
||||
* @returns Formatted error for response
|
||||
*/
|
||||
public static handleError(
|
||||
error: any,
|
||||
defaultCode: string,
|
||||
context: IErrorContext = {}
|
||||
): Record<string, any> {
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
error,
|
||||
defaultCode,
|
||||
context
|
||||
);
|
||||
|
||||
// Log the error if enabled
|
||||
if (ErrorHandler.config.logErrors) {
|
||||
logger.error(platformError.message, {
|
||||
error_code: platformError.code,
|
||||
error_name: platformError.name,
|
||||
error_severity: platformError.severity,
|
||||
error_category: platformError.category,
|
||||
error_recoverability: platformError.recoverability,
|
||||
...platformError.context,
|
||||
stack: platformError.stack
|
||||
});
|
||||
}
|
||||
|
||||
// Return formatted error for response
|
||||
const isDetailedMode = process.env.NODE_ENV !== 'production';
|
||||
return ErrorHandler.formatErrorForResponse(platformError, isDetailedMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with error handling
|
||||
*
|
||||
* @param fn Function to execute
|
||||
* @param defaultCode Default error code if the function throws
|
||||
* @param context Additional context
|
||||
* @returns Function result or error
|
||||
*/
|
||||
public static async execute<T>(
|
||||
fn: () => Promise<T>,
|
||||
defaultCode: string,
|
||||
context: IErrorContext = {}
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
throw ErrorHandler.toPlatformError(error, defaultCode, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with retries and exponential backoff
|
||||
*
|
||||
* @param fn Function to execute
|
||||
* @param defaultCode Default error code if the function throws
|
||||
* @param options Retry options
|
||||
* @param context Additional context
|
||||
* @returns Function result or error after max retries
|
||||
*/
|
||||
public static async executeWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
defaultCode: string,
|
||||
options: {
|
||||
maxAttempts?: number;
|
||||
baseDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffFactor?: number;
|
||||
retryableErrorCodes?: string[];
|
||||
retryableErrorPatterns?: RegExp[];
|
||||
onRetry?: (error: PlatformError, attempt: number, delay: number) => void;
|
||||
} = {},
|
||||
context: IErrorContext = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = ErrorHandler.config.retry.maxAttempts,
|
||||
baseDelay = ErrorHandler.config.retry.baseDelay,
|
||||
maxDelay = ErrorHandler.config.retry.maxDelay,
|
||||
backoffFactor = ErrorHandler.config.retry.backoffFactor,
|
||||
retryableErrorCodes = [],
|
||||
retryableErrorPatterns = [],
|
||||
onRetry = () => {}
|
||||
} = options;
|
||||
|
||||
let lastError: PlatformError;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
// Convert to PlatformError
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
error,
|
||||
defaultCode,
|
||||
{
|
||||
...context,
|
||||
retry: {
|
||||
currentRetry: attempt,
|
||||
maxRetries: maxAttempts,
|
||||
nextRetryAt: 0 // Will be set below if retrying
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
lastError = platformError;
|
||||
|
||||
// Check if we should retry
|
||||
const isLastAttempt = attempt >= maxAttempts - 1;
|
||||
|
||||
if (isLastAttempt) {
|
||||
// No more retries
|
||||
throw platformError;
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
const isRetryable =
|
||||
// Built-in recoverability
|
||||
platformError.recoverability === ErrorRecoverability.RECOVERABLE ||
|
||||
platformError.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
|
||||
platformError.recoverability === ErrorRecoverability.TRANSIENT ||
|
||||
// Specifically included error codes
|
||||
retryableErrorCodes.includes(platformError.code) ||
|
||||
// Matches error message patterns
|
||||
retryableErrorPatterns.some(pattern => pattern.test(platformError.message));
|
||||
|
||||
if (!isRetryable) {
|
||||
throw platformError;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
const delay = Math.min(baseDelay * Math.pow(backoffFactor, attempt), maxDelay);
|
||||
|
||||
// Add jitter to prevent thundering herd problem (±20%)
|
||||
const jitter = 0.8 + Math.random() * 0.4;
|
||||
const actualDelay = Math.floor(delay * jitter);
|
||||
|
||||
// Update nextRetryAt in error context
|
||||
const nextRetryAt = Date.now() + actualDelay;
|
||||
platformError.context.retry!.nextRetryAt = nextRetryAt;
|
||||
|
||||
// Log retry attempt
|
||||
logger.warn(`Retrying operation after error (attempt ${attempt + 1}/${maxAttempts}): ${platformError.message}`, {
|
||||
error_code: platformError.code,
|
||||
retry_attempt: attempt + 1,
|
||||
retry_max_attempts: maxAttempts,
|
||||
retry_delay_ms: actualDelay,
|
||||
retry_next_at: new Date(nextRetryAt).toISOString()
|
||||
});
|
||||
|
||||
// Call onRetry callback
|
||||
onRetry(platformError, attempt + 1, actualDelay);
|
||||
|
||||
// Wait before next retry
|
||||
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen, but TypeScript needs it
|
||||
throw lastError!;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a middleware for handling errors in HTTP requests
|
||||
*
|
||||
* @returns Middleware function
|
||||
*/
|
||||
export function createErrorHandlerMiddleware() {
|
||||
return (error: any, req: any, res: any, next: any) => {
|
||||
// Add request context
|
||||
const context: IErrorContext = {
|
||||
requestId: req.headers['x-request-id'] || req.headers['x-correlation-id'],
|
||||
component: 'HttpServer',
|
||||
operation: `${req.method} ${req.url}`,
|
||||
data: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
query: req.query,
|
||||
params: req.params,
|
||||
ip: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.headers['user-agent']
|
||||
}
|
||||
};
|
||||
|
||||
// Handle the error
|
||||
const formattedError = ErrorHandler.handleError(
|
||||
error,
|
||||
'PLATFORM_OPERATION_ERROR',
|
||||
context
|
||||
);
|
||||
|
||||
// Set status code based on error type
|
||||
let statusCode = 500;
|
||||
|
||||
if (error instanceof PlatformError) {
|
||||
// Map error categories to HTTP status codes
|
||||
switch (error.category) {
|
||||
case ErrorCategory.VALIDATION:
|
||||
statusCode = 400;
|
||||
break;
|
||||
case ErrorCategory.AUTHENTICATION:
|
||||
statusCode = 401;
|
||||
break;
|
||||
case ErrorCategory.RESOURCE:
|
||||
statusCode = 429;
|
||||
break;
|
||||
case ErrorCategory.OPERATION:
|
||||
statusCode = 400;
|
||||
break;
|
||||
default:
|
||||
statusCode = 500;
|
||||
}
|
||||
} else if (error.statusCode) {
|
||||
// Use provided status code if available
|
||||
statusCode = error.statusCode;
|
||||
}
|
||||
|
||||
// Send error response
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: formattedError
|
||||
});
|
||||
};
|
||||
}
|
||||
165
ts/errors/error.codes.ts
Normal file
165
ts/errors/error.codes.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Platform Service Error Codes
|
||||
*
|
||||
* This file contains all error codes used across the platform service.
|
||||
*
|
||||
* Format: PREFIX_ERROR_TYPE
|
||||
* - PREFIX: Component/domain prefix (e.g., EMAIL, MTA, SMS)
|
||||
* - ERROR_TYPE: Specific error type within the domain
|
||||
*/
|
||||
|
||||
// General platform errors (PLATFORM_*)
|
||||
export const PLATFORM_INITIALIZATION_ERROR = 'PLATFORM_INITIALIZATION_ERROR';
|
||||
export const PLATFORM_CONFIGURATION_ERROR = 'PLATFORM_CONFIGURATION_ERROR';
|
||||
export const PLATFORM_OPERATION_ERROR = 'PLATFORM_OPERATION_ERROR';
|
||||
export const PLATFORM_NOT_IMPLEMENTED = 'PLATFORM_NOT_IMPLEMENTED';
|
||||
export const PLATFORM_NOT_SUPPORTED = 'PLATFORM_NOT_SUPPORTED';
|
||||
export const PLATFORM_SERVICE_UNAVAILABLE = 'PLATFORM_SERVICE_UNAVAILABLE';
|
||||
|
||||
// Email service errors (EMAIL_*)
|
||||
export const EMAIL_SERVICE_ERROR = 'EMAIL_SERVICE_ERROR';
|
||||
export const EMAIL_TEMPLATE_ERROR = 'EMAIL_TEMPLATE_ERROR';
|
||||
export const EMAIL_VALIDATION_ERROR = 'EMAIL_VALIDATION_ERROR';
|
||||
export const EMAIL_SEND_ERROR = 'EMAIL_SEND_ERROR';
|
||||
export const EMAIL_RECEIVE_ERROR = 'EMAIL_RECEIVE_ERROR';
|
||||
export const EMAIL_ATTACHMENT_ERROR = 'EMAIL_ATTACHMENT_ERROR';
|
||||
export const EMAIL_PARSE_ERROR = 'EMAIL_PARSE_ERROR';
|
||||
export const EMAIL_RATE_LIMIT_EXCEEDED = 'EMAIL_RATE_LIMIT_EXCEEDED';
|
||||
|
||||
// MTA-specific errors (MTA_*)
|
||||
export const MTA_CONNECTION_ERROR = 'MTA_CONNECTION_ERROR';
|
||||
export const MTA_AUTHENTICATION_ERROR = 'MTA_AUTHENTICATION_ERROR';
|
||||
export const MTA_DELIVERY_ERROR = 'MTA_DELIVERY_ERROR';
|
||||
export const MTA_CONFIGURATION_ERROR = 'MTA_CONFIGURATION_ERROR';
|
||||
export const MTA_DNS_ERROR = 'MTA_DNS_ERROR';
|
||||
export const MTA_TIMEOUT_ERROR = 'MTA_TIMEOUT_ERROR';
|
||||
export const MTA_PROTOCOL_ERROR = 'MTA_PROTOCOL_ERROR';
|
||||
|
||||
// Bounce management errors (BOUNCE_*)
|
||||
export const BOUNCE_PROCESSING_ERROR = 'BOUNCE_PROCESSING_ERROR';
|
||||
export const BOUNCE_STORAGE_ERROR = 'BOUNCE_STORAGE_ERROR';
|
||||
export const BOUNCE_CLASSIFICATION_ERROR = 'BOUNCE_CLASSIFICATION_ERROR';
|
||||
|
||||
// Email authentication errors (AUTH_*)
|
||||
export const AUTH_SPF_ERROR = 'AUTH_SPF_ERROR';
|
||||
export const AUTH_DKIM_ERROR = 'AUTH_DKIM_ERROR';
|
||||
export const AUTH_DMARC_ERROR = 'AUTH_DMARC_ERROR';
|
||||
export const AUTH_KEY_ERROR = 'AUTH_KEY_ERROR';
|
||||
|
||||
// Content scanning errors (SCAN_*)
|
||||
export const SCAN_ANALYSIS_ERROR = 'SCAN_ANALYSIS_ERROR';
|
||||
export const SCAN_MALWARE_DETECTED = 'SCAN_MALWARE_DETECTED';
|
||||
export const SCAN_PHISHING_DETECTED = 'SCAN_PHISHING_DETECTED';
|
||||
export const SCAN_CONTENT_REJECTED = 'SCAN_CONTENT_REJECTED';
|
||||
|
||||
// IP and reputation errors (REPUTATION_*)
|
||||
export const REPUTATION_CHECK_ERROR = 'REPUTATION_CHECK_ERROR';
|
||||
export const REPUTATION_DATA_ERROR = 'REPUTATION_DATA_ERROR';
|
||||
export const REPUTATION_BLOCKLIST_ERROR = 'REPUTATION_BLOCKLIST_ERROR';
|
||||
export const REPUTATION_UPDATE_ERROR = 'REPUTATION_UPDATE_ERROR';
|
||||
|
||||
// IP warmup errors (WARMUP_*)
|
||||
export const WARMUP_ALLOCATION_ERROR = 'WARMUP_ALLOCATION_ERROR';
|
||||
export const WARMUP_LIMIT_EXCEEDED = 'WARMUP_LIMIT_EXCEEDED';
|
||||
export const WARMUP_SCHEDULE_ERROR = 'WARMUP_SCHEDULE_ERROR';
|
||||
|
||||
// Network and connectivity errors (NETWORK_*)
|
||||
export const NETWORK_CONNECTION_ERROR = 'NETWORK_CONNECTION_ERROR';
|
||||
export const NETWORK_TIMEOUT = 'NETWORK_TIMEOUT';
|
||||
export const NETWORK_DNS_ERROR = 'NETWORK_DNS_ERROR';
|
||||
export const NETWORK_TLS_ERROR = 'NETWORK_TLS_ERROR';
|
||||
|
||||
// Queue and processing errors (QUEUE_*)
|
||||
export const QUEUE_FULL_ERROR = 'QUEUE_FULL_ERROR';
|
||||
export const QUEUE_PROCESSING_ERROR = 'QUEUE_PROCESSING_ERROR';
|
||||
export const QUEUE_PERSISTENCE_ERROR = 'QUEUE_PERSISTENCE_ERROR';
|
||||
export const QUEUE_ITEM_NOT_FOUND = 'QUEUE_ITEM_NOT_FOUND';
|
||||
|
||||
// DcRouter errors (DCR_*)
|
||||
export const DCR_ROUTING_ERROR = 'DCR_ROUTING_ERROR';
|
||||
export const DCR_CONFIGURATION_ERROR = 'DCR_CONFIGURATION_ERROR';
|
||||
export const DCR_PROXY_ERROR = 'DCR_PROXY_ERROR';
|
||||
export const DCR_DOMAIN_ERROR = 'DCR_DOMAIN_ERROR';
|
||||
|
||||
// SMS service errors (SMS_*)
|
||||
export const SMS_SERVICE_ERROR = 'SMS_SERVICE_ERROR';
|
||||
export const SMS_SEND_ERROR = 'SMS_SEND_ERROR';
|
||||
export const SMS_VALIDATION_ERROR = 'SMS_VALIDATION_ERROR';
|
||||
export const SMS_RATE_LIMIT_EXCEEDED = 'SMS_RATE_LIMIT_EXCEEDED';
|
||||
|
||||
// Storage errors (STORAGE_*)
|
||||
export const STORAGE_WRITE_ERROR = 'STORAGE_WRITE_ERROR';
|
||||
export const STORAGE_READ_ERROR = 'STORAGE_READ_ERROR';
|
||||
export const STORAGE_DELETE_ERROR = 'STORAGE_DELETE_ERROR';
|
||||
export const STORAGE_QUOTA_EXCEEDED = 'STORAGE_QUOTA_EXCEEDED';
|
||||
|
||||
// Rule management errors (RULE_*)
|
||||
export const RULE_VALIDATION_ERROR = 'RULE_VALIDATION_ERROR';
|
||||
export const RULE_EXECUTION_ERROR = 'RULE_EXECUTION_ERROR';
|
||||
export const RULE_NOT_FOUND = 'RULE_NOT_FOUND';
|
||||
|
||||
// Type definitions for error severity
|
||||
export enum ErrorSeverity {
|
||||
/** Critical errors that require immediate attention */
|
||||
CRITICAL = 'CRITICAL',
|
||||
|
||||
/** High-impact errors that may affect service functioning */
|
||||
HIGH = 'HIGH',
|
||||
|
||||
/** Medium-impact errors that cause partial degradation */
|
||||
MEDIUM = 'MEDIUM',
|
||||
|
||||
/** Low-impact errors that have minimal or local impact */
|
||||
LOW = 'LOW',
|
||||
|
||||
/** Informational errors that are not problematic */
|
||||
INFO = 'INFO'
|
||||
}
|
||||
|
||||
// Type definitions for error categories
|
||||
export enum ErrorCategory {
|
||||
/** Errors related to configuration */
|
||||
CONFIGURATION = 'CONFIGURATION',
|
||||
|
||||
/** Errors related to network connectivity */
|
||||
CONNECTIVITY = 'CONNECTIVITY',
|
||||
|
||||
/** Errors related to authentication/authorization */
|
||||
AUTHENTICATION = 'AUTHENTICATION',
|
||||
|
||||
/** Errors related to data validation */
|
||||
VALIDATION = 'VALIDATION',
|
||||
|
||||
/** Errors related to resource availability */
|
||||
RESOURCE = 'RESOURCE',
|
||||
|
||||
/** Errors related to service operations */
|
||||
OPERATION = 'OPERATION',
|
||||
|
||||
/** Errors related to third-party integrations */
|
||||
INTEGRATION = 'INTEGRATION',
|
||||
|
||||
/** Errors related to security */
|
||||
SECURITY = 'SECURITY',
|
||||
|
||||
/** Errors related to data storage */
|
||||
STORAGE = 'STORAGE',
|
||||
|
||||
/** Errors that don't fit into other categories */
|
||||
OTHER = 'OTHER'
|
||||
}
|
||||
|
||||
// Type definitions for error recoverability
|
||||
export enum ErrorRecoverability {
|
||||
/** Error cannot be automatically recovered from */
|
||||
NON_RECOVERABLE = 'NON_RECOVERABLE',
|
||||
|
||||
/** Error might be recoverable with retry */
|
||||
MAYBE_RECOVERABLE = 'MAYBE_RECOVERABLE',
|
||||
|
||||
/** Error is definitely recoverable with retries */
|
||||
RECOVERABLE = 'RECOVERABLE',
|
||||
|
||||
/** Error is transient and should resolve without action */
|
||||
TRANSIENT = 'TRANSIENT'
|
||||
}
|
||||
193
ts/errors/index.ts
Normal file
193
ts/errors/index.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Platform Service Error System
|
||||
*
|
||||
* This module provides a comprehensive error handling system for the Platform Service,
|
||||
* with structured error types, error codes, and consistent patterns for logging and recovery.
|
||||
*/
|
||||
|
||||
// Export error codes and types
|
||||
export * from './error.codes.js';
|
||||
|
||||
// Export base error classes
|
||||
export * from './base.errors.js';
|
||||
|
||||
// Export domain-specific error classes
|
||||
export * from './reputation.errors.js';
|
||||
|
||||
// Export error handler
|
||||
export * from './error-handler.js';
|
||||
|
||||
// Export utility function to create specific error types based on the error category
|
||||
import { getErrorClassForCategory } from './base.errors.js';
|
||||
export { getErrorClassForCategory };
|
||||
|
||||
// Import needed classes for utility functions
|
||||
import { PlatformError } from './base.errors.js';
|
||||
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
|
||||
|
||||
/**
|
||||
* Create a typed error from a standard Error
|
||||
* Useful for converting errors from external libraries or APIs
|
||||
*
|
||||
* @param error Standard error to convert
|
||||
* @param code Error code to assign
|
||||
* @param contextData Additional context data
|
||||
* @returns Typed PlatformError
|
||||
*/
|
||||
export function fromError(
|
||||
error: Error,
|
||||
code: string,
|
||||
contextData: Record<string, any> = {}
|
||||
): PlatformError {
|
||||
return new PlatformError(
|
||||
error.message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
{
|
||||
data: {
|
||||
...contextData,
|
||||
originalError: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error is retryable
|
||||
*
|
||||
* @param error Error to check
|
||||
* @returns Boolean indicating if the error should be retried
|
||||
*/
|
||||
export function isRetryable(error: any): boolean {
|
||||
// If it's our platform error, use its recoverability property
|
||||
if (error && typeof error === 'object' && 'recoverability' in error) {
|
||||
return error.recoverability === ErrorRecoverability.RECOVERABLE ||
|
||||
error.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
|
||||
error.recoverability === ErrorRecoverability.TRANSIENT;
|
||||
}
|
||||
|
||||
// Check if it's a network error (these are often transient)
|
||||
if (error && typeof error === 'object' && error.code) {
|
||||
const networkErrors = [
|
||||
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EHOSTUNREACH',
|
||||
'ENETUNREACH', 'ENOTFOUND', 'EPROTO', 'ECONNABORTED'
|
||||
];
|
||||
|
||||
return networkErrors.includes(error.code);
|
||||
}
|
||||
|
||||
// By default, we can't determine if the error is retryable
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wrapped version of a function that catches errors
|
||||
* and converts them to typed PlatformErrors
|
||||
*
|
||||
* @param fn Function to wrap
|
||||
* @param errorCode Default error code to use
|
||||
* @param contextData Additional context data
|
||||
* @returns Wrapped function
|
||||
*/
|
||||
export function withErrorHandling<T extends (...args: any[]) => Promise<any>>(
|
||||
fn: T,
|
||||
errorCode: string,
|
||||
contextData: Record<string, any> = {}
|
||||
): T {
|
||||
return (async function(...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
// Already a typed error, rethrow
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw fromError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
errorCode,
|
||||
{
|
||||
...contextData,
|
||||
fnName: fn.name,
|
||||
args: args.map(arg =>
|
||||
typeof arg === 'object'
|
||||
? '[Object]'
|
||||
: String(arg).substring(0, 100)
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff
|
||||
*
|
||||
* @param fn Function to retry
|
||||
* @param options Retry options
|
||||
* @returns Function result or throws after max retries
|
||||
*/
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffFactor?: number;
|
||||
retryableErrors?: Array<string | RegExp>;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
initialDelay = 1000,
|
||||
maxDelay = 30000,
|
||||
backoffFactor = 2,
|
||||
retryableErrors = []
|
||||
} = options;
|
||||
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error
|
||||
? error
|
||||
: new Error(String(error));
|
||||
|
||||
// Check if we should retry
|
||||
const shouldRetry = attempt < maxRetries && (
|
||||
isRetryable(error) ||
|
||||
retryableErrors.some(pattern => {
|
||||
if (typeof pattern === 'string') {
|
||||
return lastError.message.includes(pattern);
|
||||
}
|
||||
return pattern.test(lastError.message);
|
||||
})
|
||||
);
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
const delay = Math.min(initialDelay * Math.pow(backoffFactor, attempt), maxDelay);
|
||||
|
||||
// Add jitter to prevent thundering herd problem (±20%)
|
||||
const jitter = 0.8 + Math.random() * 0.4;
|
||||
const actualDelay = Math.floor(delay * jitter);
|
||||
|
||||
// Wait before next retry
|
||||
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen, but TypeScript needs it
|
||||
throw lastError!;
|
||||
}
|
||||
422
ts/errors/reputation.errors.ts
Normal file
422
ts/errors/reputation.errors.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
PlatformError,
|
||||
OperationError,
|
||||
ResourceError
|
||||
} from './base.errors.js';
|
||||
import type { IErrorContext } from './base.errors.js';
|
||||
|
||||
import {
|
||||
REPUTATION_CHECK_ERROR,
|
||||
REPUTATION_DATA_ERROR,
|
||||
REPUTATION_BLOCKLIST_ERROR,
|
||||
REPUTATION_UPDATE_ERROR,
|
||||
WARMUP_ALLOCATION_ERROR,
|
||||
WARMUP_LIMIT_EXCEEDED,
|
||||
WARMUP_SCHEDULE_ERROR
|
||||
} from './error.codes.js';
|
||||
|
||||
/**
|
||||
* Base class for reputation-related errors
|
||||
*/
|
||||
export class ReputationError extends OperationError {
|
||||
/**
|
||||
* Creates a new reputation error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, code, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for reputation check errors
|
||||
*/
|
||||
export class ReputationCheckError extends ReputationError {
|
||||
/**
|
||||
* Creates a new reputation check error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, REPUTATION_CHECK_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ReputationCheckError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for an IP reputation check error
|
||||
*
|
||||
* @param ip IP address
|
||||
* @param provider Reputation provider
|
||||
* @param originalError Original error
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static ipCheckFailed(
|
||||
ip: string,
|
||||
provider: string,
|
||||
originalError?: Error,
|
||||
context: IErrorContext = {}
|
||||
): ReputationCheckError {
|
||||
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||
return new ReputationCheckError(
|
||||
`Failed to check reputation for IP ${ip} with provider ${provider}${errorMsg}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
ip,
|
||||
provider,
|
||||
originalError: originalError ? {
|
||||
message: originalError.message,
|
||||
stack: originalError.stack
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for a domain reputation check error
|
||||
*
|
||||
* @param domain Domain
|
||||
* @param provider Reputation provider
|
||||
* @param originalError Original error
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static domainCheckFailed(
|
||||
domain: string,
|
||||
provider: string,
|
||||
originalError?: Error,
|
||||
context: IErrorContext = {}
|
||||
): ReputationCheckError {
|
||||
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||
return new ReputationCheckError(
|
||||
`Failed to check reputation for domain ${domain} with provider ${provider}${errorMsg}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
domain,
|
||||
provider,
|
||||
originalError: originalError ? {
|
||||
message: originalError.message,
|
||||
stack: originalError.stack
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for reputation data errors
|
||||
*/
|
||||
export class ReputationDataError extends ReputationError {
|
||||
/**
|
||||
* Creates a new reputation data error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, REPUTATION_DATA_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ReputationDataError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for a data access error
|
||||
*
|
||||
* @param entity Entity type (domain, ip)
|
||||
* @param entityId Entity identifier
|
||||
* @param operation Operation that failed (read, write, update)
|
||||
* @param originalError Original error
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static dataAccessFailed(
|
||||
entity: string,
|
||||
entityId: string,
|
||||
operation: string,
|
||||
originalError?: Error,
|
||||
context: IErrorContext = {}
|
||||
): ReputationDataError {
|
||||
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||
return new ReputationDataError(
|
||||
`Failed to ${operation} reputation data for ${entity} ${entityId}${errorMsg}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
entity,
|
||||
entityId,
|
||||
operation,
|
||||
originalError: originalError ? {
|
||||
message: originalError.message,
|
||||
stack: originalError.stack
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for blocklist-related errors
|
||||
*/
|
||||
export class BlocklistError extends ReputationError {
|
||||
/**
|
||||
* Creates a new blocklist error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, REPUTATION_BLOCKLIST_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof BlocklistError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for an entity found on a blocklist
|
||||
*
|
||||
* @param entity Entity type (domain, ip)
|
||||
* @param entityId Entity identifier
|
||||
* @param blocklist Blocklist name
|
||||
* @param reason Reason for listing (if available)
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static entityBlocked(
|
||||
entity: string,
|
||||
entityId: string,
|
||||
blocklist: string,
|
||||
reason?: string,
|
||||
context: IErrorContext = {}
|
||||
): BlocklistError {
|
||||
const reasonText = reason ? ` (${reason})` : '';
|
||||
return new BlocklistError(
|
||||
`${entity.charAt(0).toUpperCase() + entity.slice(1)} ${entityId} is listed on blocklist ${blocklist}${reasonText}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
entity,
|
||||
entityId,
|
||||
blocklist,
|
||||
reason
|
||||
},
|
||||
userMessage: `The ${entity} ${entityId} is on a blocklist. This may affect email deliverability.`
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for reputation update errors
|
||||
*/
|
||||
export class ReputationUpdateError extends ReputationError {
|
||||
/**
|
||||
* Creates a new reputation update error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, REPUTATION_UPDATE_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ReputationUpdateError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for IP warmup allocation errors
|
||||
*/
|
||||
export class WarmupAllocationError extends ReputationError {
|
||||
/**
|
||||
* Creates a new warmup allocation error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, WARMUP_ALLOCATION_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof WarmupAllocationError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for no available IPs
|
||||
*
|
||||
* @param domain Domain requesting an IP
|
||||
* @param policy Allocation policy that was used
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static noAvailableIps(
|
||||
domain: string,
|
||||
policy: string,
|
||||
context: IErrorContext = {}
|
||||
): WarmupAllocationError {
|
||||
return new WarmupAllocationError(
|
||||
`No available IPs for domain ${domain} using ${policy} allocation policy`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
domain,
|
||||
policy
|
||||
},
|
||||
userMessage: `No available sending IPs for ${domain}.`
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for IP warmup limit exceeded errors
|
||||
*/
|
||||
export class WarmupLimitError extends ResourceError {
|
||||
/**
|
||||
* Creates a new warmup limit error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, WARMUP_LIMIT_EXCEEDED, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof WarmupLimitError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for daily sending limit exceeded
|
||||
*
|
||||
* @param ip IP address
|
||||
* @param domain Domain
|
||||
* @param limit Daily limit
|
||||
* @param sent Number of emails sent
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static dailyLimitExceeded(
|
||||
ip: string,
|
||||
domain: string,
|
||||
limit: number,
|
||||
sent: number,
|
||||
context: IErrorContext = {}
|
||||
): WarmupLimitError {
|
||||
return new WarmupLimitError(
|
||||
`Daily sending limit exceeded for IP ${ip} and domain ${domain}: ${sent}/${limit}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
ip,
|
||||
domain,
|
||||
limit,
|
||||
sent
|
||||
},
|
||||
userMessage: `Daily sending limit reached for ${domain}.`
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for IP warmup schedule errors
|
||||
*/
|
||||
export class WarmupScheduleError extends ReputationError {
|
||||
/**
|
||||
* Creates a new warmup schedule error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, WARMUP_SCHEDULE_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof WarmupScheduleError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
13
ts/index.ts
13
ts/index.ts
@@ -1,4 +1,13 @@
|
||||
export * from './00_commitinfo_data.js';
|
||||
import { SzPlatformService } from './platformservice.js';
|
||||
|
||||
export const runCli = async () => {}
|
||||
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
|
||||
export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
|
||||
// DcRouter
|
||||
export * from './classes.dcrouter.js';
|
||||
|
||||
// RADIUS module
|
||||
export * from './radius/index.js';
|
||||
|
||||
export const runCli = async () => {};
|
||||
|
||||
86
ts/logger.ts
86
ts/logger.ts
@@ -1,9 +1,91 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const logger = new plugins.smartlog.Smartlog({
|
||||
// Map NODE_ENV to valid TEnvironment
|
||||
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||
const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
|
||||
'development': 'local',
|
||||
'test': 'test',
|
||||
'staging': 'staging',
|
||||
'production': 'production'
|
||||
};
|
||||
|
||||
// Default Smartlog instance
|
||||
const baseLogger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: 'production',
|
||||
environment: envMap[nodeEnv] || 'production',
|
||||
runtime: 'node',
|
||||
zone: 'serve.zone',
|
||||
}
|
||||
});
|
||||
|
||||
// Extended logger compatible with the original enhanced logger API
|
||||
class StandardLogger {
|
||||
private defaultContext: Record<string, any> = {};
|
||||
private correlationId: string | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
// Log methods
|
||||
public log(level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context: Record<string, any> = {}) {
|
||||
const combinedContext = {
|
||||
...this.defaultContext,
|
||||
...context
|
||||
};
|
||||
|
||||
if (this.correlationId) {
|
||||
combinedContext.correlation_id = this.correlationId;
|
||||
}
|
||||
|
||||
baseLogger.log(level, message, combinedContext);
|
||||
}
|
||||
|
||||
public error(message: string, context: Record<string, any> = {}) {
|
||||
this.log('error', message, context);
|
||||
}
|
||||
|
||||
public warn(message: string, context: Record<string, any> = {}) {
|
||||
this.log('warn', message, context);
|
||||
}
|
||||
|
||||
public info(message: string, context: Record<string, any> = {}) {
|
||||
this.log('info', message, context);
|
||||
}
|
||||
|
||||
public success(message: string, context: Record<string, any> = {}) {
|
||||
this.log('success', message, context);
|
||||
}
|
||||
|
||||
public debug(message: string, context: Record<string, any> = {}) {
|
||||
this.log('debug', message, context);
|
||||
}
|
||||
|
||||
// Context management
|
||||
public setContext(context: Record<string, any>, overwrite: boolean = false) {
|
||||
if (overwrite) {
|
||||
this.defaultContext = context;
|
||||
} else {
|
||||
this.defaultContext = {
|
||||
...this.defaultContext,
|
||||
...context
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Correlation ID management
|
||||
public setCorrelationId(id: string | null = null): string {
|
||||
this.correlationId = id || randomUUID();
|
||||
return this.correlationId;
|
||||
}
|
||||
|
||||
public getCorrelationId(): string | null {
|
||||
return this.correlationId;
|
||||
}
|
||||
|
||||
public clearCorrelationId(): void {
|
||||
this.correlationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const logger = new StandardLogger();
|
||||
|
||||
75
ts/monitoring/classes.metricscache.ts
Normal file
75
ts/monitoring/classes.metricscache.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface ICacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class MetricsCache {
|
||||
private cache = new Map<string, ICacheEntry<any>>();
|
||||
private readonly defaultTTL: number;
|
||||
|
||||
constructor(defaultTTL: number = 500) {
|
||||
this.defaultTTL = defaultTTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data or compute and cache it
|
||||
*/
|
||||
public get<T>(key: string, computeFn: () => T | Promise<T>, ttl?: number): T | Promise<T> {
|
||||
const cached = this.cache.get(key);
|
||||
const now = Date.now();
|
||||
const actualTTL = ttl ?? this.defaultTTL;
|
||||
|
||||
if (cached && (now - cached.timestamp) < actualTTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const result = computeFn();
|
||||
|
||||
// Handle both sync and async compute functions
|
||||
if (result instanceof Promise) {
|
||||
return result.then(data => {
|
||||
this.cache.set(key, { data, timestamp: now });
|
||||
return data;
|
||||
});
|
||||
} else {
|
||||
this.cache.set(key, { data: result, timestamp: now });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a specific cache entry
|
||||
*/
|
||||
public invalidate(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache entries
|
||||
*/
|
||||
public clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
public getStats(): { size: number; keys: string[] } {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
keys: Array.from(this.cache.keys())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries
|
||||
*/
|
||||
public cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now - entry.timestamp > this.defaultTTL) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
522
ts/monitoring/classes.metricsmanager.ts
Normal file
522
ts/monitoring/classes.metricsmanager.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { DcRouter } from '../classes.dcrouter.js';
|
||||
import { MetricsCache } from './classes.metricscache.js';
|
||||
|
||||
export class MetricsManager {
|
||||
private logger: plugins.smartlog.Smartlog;
|
||||
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
||||
private dcRouter: DcRouter;
|
||||
private resetInterval?: NodeJS.Timeout;
|
||||
private metricsCache: MetricsCache;
|
||||
|
||||
// Constants
|
||||
private readonly MAX_TOP_DOMAINS = 1000; // Limit topDomains Map size
|
||||
|
||||
// Track email-specific metrics
|
||||
private emailMetrics = {
|
||||
sentToday: 0,
|
||||
receivedToday: 0,
|
||||
failedToday: 0,
|
||||
bouncedToday: 0,
|
||||
queueSize: 0,
|
||||
lastResetDate: new Date().toDateString(),
|
||||
deliveryTimes: [] as number[], // Track delivery times in ms
|
||||
recipients: new Map<string, number>(), // Track email count by recipient
|
||||
recentActivity: [] as Array<{ timestamp: number; type: string; details: string }>,
|
||||
};
|
||||
|
||||
// Track DNS-specific metrics
|
||||
private dnsMetrics = {
|
||||
totalQueries: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
queryTypes: {} as Record<string, number>,
|
||||
topDomains: new Map<string, number>(),
|
||||
lastResetDate: new Date().toDateString(),
|
||||
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
|
||||
responseTimes: [] as number[], // Track response times in ms
|
||||
};
|
||||
|
||||
// Track security-specific metrics
|
||||
private securityMetrics = {
|
||||
blockedIPs: 0,
|
||||
authFailures: 0,
|
||||
spamDetected: 0,
|
||||
malwareDetected: 0,
|
||||
phishingDetected: 0,
|
||||
lastResetDate: new Date().toDateString(),
|
||||
incidents: [] as Array<{ timestamp: number; type: string; severity: string; details: string }>,
|
||||
};
|
||||
|
||||
constructor(dcRouter: DcRouter) {
|
||||
this.dcRouter = dcRouter;
|
||||
// Create a new Smartlog instance for metrics
|
||||
this.logger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: 'production',
|
||||
runtime: 'node',
|
||||
zone: 'dcrouter-metrics',
|
||||
}
|
||||
});
|
||||
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.logger, 'dcrouter');
|
||||
// Initialize metrics cache with 500ms TTL
|
||||
this.metricsCache = new MetricsCache(500);
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
// Start SmartMetrics collection
|
||||
this.smartMetrics.start();
|
||||
|
||||
// Reset daily counters at midnight
|
||||
this.resetInterval = setInterval(() => {
|
||||
const currentDate = new Date().toDateString();
|
||||
|
||||
if (currentDate !== this.emailMetrics.lastResetDate) {
|
||||
this.emailMetrics.sentToday = 0;
|
||||
this.emailMetrics.receivedToday = 0;
|
||||
this.emailMetrics.failedToday = 0;
|
||||
this.emailMetrics.bouncedToday = 0;
|
||||
this.emailMetrics.deliveryTimes = [];
|
||||
this.emailMetrics.recipients.clear();
|
||||
this.emailMetrics.recentActivity = [];
|
||||
this.emailMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
if (currentDate !== this.dnsMetrics.lastResetDate) {
|
||||
this.dnsMetrics.totalQueries = 0;
|
||||
this.dnsMetrics.cacheHits = 0;
|
||||
this.dnsMetrics.cacheMisses = 0;
|
||||
this.dnsMetrics.queryTypes = {};
|
||||
this.dnsMetrics.topDomains.clear();
|
||||
this.dnsMetrics.queryTimestamps = [];
|
||||
this.dnsMetrics.responseTimes = [];
|
||||
this.dnsMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
if (currentDate !== this.securityMetrics.lastResetDate) {
|
||||
this.securityMetrics.blockedIPs = 0;
|
||||
this.securityMetrics.authFailures = 0;
|
||||
this.securityMetrics.spamDetected = 0;
|
||||
this.securityMetrics.malwareDetected = 0;
|
||||
this.securityMetrics.phishingDetected = 0;
|
||||
this.securityMetrics.incidents = [];
|
||||
this.securityMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
}, 60000); // Check every minute
|
||||
|
||||
this.logger.log('info', 'MetricsManager started');
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
// Clear the reset interval
|
||||
if (this.resetInterval) {
|
||||
clearInterval(this.resetInterval);
|
||||
this.resetInterval = undefined;
|
||||
}
|
||||
|
||||
this.smartMetrics.stop();
|
||||
this.logger.log('info', 'MetricsManager stopped');
|
||||
}
|
||||
|
||||
// Get server metrics from SmartMetrics and SmartProxy
|
||||
public async getServerStats() {
|
||||
return this.metricsCache.get('serverStats', async () => {
|
||||
const smartMetricsData = await this.smartMetrics.getMetrics();
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
||||
|
||||
return {
|
||||
uptime: process.uptime(),
|
||||
startTime: Date.now() - (process.uptime() * 1000),
|
||||
memoryUsage: {
|
||||
heapUsed: process.memoryUsage().heapUsed,
|
||||
heapTotal: process.memoryUsage().heapTotal,
|
||||
external: process.memoryUsage().external,
|
||||
rss: process.memoryUsage().rss,
|
||||
// Add SmartMetrics memory data
|
||||
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||
},
|
||||
cpuUsage: {
|
||||
user: parseFloat(smartMetricsData.cpuUsageText || '0'),
|
||||
system: 0, // SmartMetrics doesn't separate user/system
|
||||
},
|
||||
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
|
||||
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
|
||||
requestsPerSecond: proxyMetrics ? proxyMetrics.requests.perSecond() : 0,
|
||||
throughput: proxyMetrics ? {
|
||||
bytesIn: proxyMetrics.totals.bytesIn(),
|
||||
bytesOut: proxyMetrics.totals.bytesOut()
|
||||
} : { bytesIn: 0, bytesOut: 0 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get email metrics
|
||||
public async getEmailStats() {
|
||||
return this.metricsCache.get('emailStats', () => {
|
||||
// Calculate average delivery time
|
||||
const avgDeliveryTime = this.emailMetrics.deliveryTimes.length > 0
|
||||
? this.emailMetrics.deliveryTimes.reduce((a, b) => a + b, 0) / this.emailMetrics.deliveryTimes.length
|
||||
: 0;
|
||||
|
||||
// Get top recipients
|
||||
const topRecipients = Array.from(this.emailMetrics.recipients.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([email, count]) => ({ email, count }));
|
||||
|
||||
// Get recent activity (last 50 entries)
|
||||
const recentActivity = this.emailMetrics.recentActivity.slice(-50);
|
||||
|
||||
return {
|
||||
sentToday: this.emailMetrics.sentToday,
|
||||
receivedToday: this.emailMetrics.receivedToday,
|
||||
failedToday: this.emailMetrics.failedToday,
|
||||
bounceRate: this.emailMetrics.bouncedToday > 0
|
||||
? (this.emailMetrics.bouncedToday / this.emailMetrics.sentToday) * 100
|
||||
: 0,
|
||||
deliveryRate: this.emailMetrics.sentToday > 0
|
||||
? ((this.emailMetrics.sentToday - this.emailMetrics.failedToday) / this.emailMetrics.sentToday) * 100
|
||||
: 100,
|
||||
queueSize: this.emailMetrics.queueSize,
|
||||
averageDeliveryTime: Math.round(avgDeliveryTime),
|
||||
topRecipients,
|
||||
recentActivity,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get DNS metrics
|
||||
public async getDnsStats() {
|
||||
return this.metricsCache.get('dnsStats', () => {
|
||||
const cacheHitRate = this.dnsMetrics.totalQueries > 0
|
||||
? (this.dnsMetrics.cacheHits / this.dnsMetrics.totalQueries) * 100
|
||||
: 0;
|
||||
|
||||
const topDomains = Array.from(this.dnsMetrics.topDomains.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([domain, count]) => ({ domain, count }));
|
||||
|
||||
// Calculate queries per second from recent timestamps
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60000;
|
||||
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
|
||||
const queriesPerSecond = recentQueries.length / 60;
|
||||
|
||||
// Calculate average response time
|
||||
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
||||
? this.dnsMetrics.responseTimes.reduce((a, b) => a + b, 0) / this.dnsMetrics.responseTimes.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
queriesPerSecond: Math.round(queriesPerSecond * 10) / 10,
|
||||
totalQueries: this.dnsMetrics.totalQueries,
|
||||
cacheHits: this.dnsMetrics.cacheHits,
|
||||
cacheMisses: this.dnsMetrics.cacheMisses,
|
||||
cacheHitRate: cacheHitRate,
|
||||
topDomains: topDomains,
|
||||
queryTypes: this.dnsMetrics.queryTypes,
|
||||
averageResponseTime: Math.round(avgResponseTime),
|
||||
activeDomains: this.dnsMetrics.topDomains.size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get security metrics
|
||||
public async getSecurityStats() {
|
||||
return this.metricsCache.get('securityStats', () => {
|
||||
// Get recent incidents (last 20)
|
||||
const recentIncidents = this.securityMetrics.incidents.slice(-20);
|
||||
|
||||
return {
|
||||
blockedIPs: this.securityMetrics.blockedIPs,
|
||||
authFailures: this.securityMetrics.authFailures,
|
||||
spamDetected: this.securityMetrics.spamDetected,
|
||||
malwareDetected: this.securityMetrics.malwareDetected,
|
||||
phishingDetected: this.securityMetrics.phishingDetected,
|
||||
totalThreatsBlocked: this.securityMetrics.spamDetected +
|
||||
this.securityMetrics.malwareDetected +
|
||||
this.securityMetrics.phishingDetected,
|
||||
recentIncidents,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get connection info from SmartProxy
|
||||
public async getConnectionInfo() {
|
||||
return this.metricsCache.get('connectionInfo', () => {
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
|
||||
if (!proxyMetrics) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const connectionInfo = [];
|
||||
|
||||
for (const [routeName, count] of connectionsByRoute) {
|
||||
connectionInfo.push({
|
||||
type: 'https',
|
||||
count,
|
||||
source: routeName,
|
||||
lastActivity: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return connectionInfo;
|
||||
});
|
||||
}
|
||||
|
||||
// Email event tracking methods
|
||||
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
|
||||
this.emailMetrics.sentToday++;
|
||||
|
||||
if (recipient) {
|
||||
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
||||
this.emailMetrics.recipients.set(recipient, count + 1);
|
||||
}
|
||||
|
||||
if (deliveryTimeMs) {
|
||||
this.emailMetrics.deliveryTimes.push(deliveryTimeMs);
|
||||
// Keep only last 1000 delivery times
|
||||
if (this.emailMetrics.deliveryTimes.length > 1000) {
|
||||
this.emailMetrics.deliveryTimes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'sent',
|
||||
details: recipient || 'unknown',
|
||||
});
|
||||
|
||||
// Keep only last 1000 activities
|
||||
if (this.emailMetrics.recentActivity.length > 1000) {
|
||||
this.emailMetrics.recentActivity.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackEmailReceived(sender?: string): void {
|
||||
this.emailMetrics.receivedToday++;
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'received',
|
||||
details: sender || 'unknown',
|
||||
});
|
||||
|
||||
// Keep only last 1000 activities
|
||||
if (this.emailMetrics.recentActivity.length > 1000) {
|
||||
this.emailMetrics.recentActivity.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackEmailFailed(recipient?: string, reason?: string): void {
|
||||
this.emailMetrics.failedToday++;
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'failed',
|
||||
details: `${recipient || 'unknown'}: ${reason || 'unknown error'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 activities
|
||||
if (this.emailMetrics.recentActivity.length > 1000) {
|
||||
this.emailMetrics.recentActivity.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackEmailBounced(recipient?: string): void {
|
||||
this.emailMetrics.bouncedToday++;
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'bounced',
|
||||
details: recipient || 'unknown',
|
||||
});
|
||||
|
||||
// Keep only last 1000 activities
|
||||
if (this.emailMetrics.recentActivity.length > 1000) {
|
||||
this.emailMetrics.recentActivity.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public updateQueueSize(size: number): void {
|
||||
this.emailMetrics.queueSize = size;
|
||||
}
|
||||
|
||||
// DNS event tracking methods
|
||||
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void {
|
||||
this.dnsMetrics.totalQueries++;
|
||||
|
||||
if (cacheHit) {
|
||||
this.dnsMetrics.cacheHits++;
|
||||
} else {
|
||||
this.dnsMetrics.cacheMisses++;
|
||||
}
|
||||
|
||||
// Track query timestamp
|
||||
this.dnsMetrics.queryTimestamps.push(Date.now());
|
||||
|
||||
// Keep only timestamps from last 5 minutes
|
||||
const fiveMinutesAgo = Date.now() - 300000;
|
||||
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
|
||||
|
||||
// Track response time if provided
|
||||
if (responseTimeMs) {
|
||||
this.dnsMetrics.responseTimes.push(responseTimeMs);
|
||||
// Keep only last 1000 response times
|
||||
if (this.dnsMetrics.responseTimes.length > 1000) {
|
||||
this.dnsMetrics.responseTimes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Track query types
|
||||
this.dnsMetrics.queryTypes[queryType] = (this.dnsMetrics.queryTypes[queryType] || 0) + 1;
|
||||
|
||||
// Track top domains with size limit
|
||||
const currentCount = this.dnsMetrics.topDomains.get(domain) || 0;
|
||||
this.dnsMetrics.topDomains.set(domain, currentCount + 1);
|
||||
|
||||
// If we've exceeded the limit, remove the least accessed domains
|
||||
if (this.dnsMetrics.topDomains.size > this.MAX_TOP_DOMAINS) {
|
||||
// Convert to array, sort by count, and keep only top domains
|
||||
const sortedDomains = Array.from(this.dnsMetrics.topDomains.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8)); // Keep 80% to avoid frequent cleanup
|
||||
|
||||
// Clear and repopulate with top domains
|
||||
this.dnsMetrics.topDomains.clear();
|
||||
sortedDomains.forEach(([domain, count]) => {
|
||||
this.dnsMetrics.topDomains.set(domain, count);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Security event tracking methods
|
||||
public trackBlockedIP(ip?: string, reason?: string): void {
|
||||
this.securityMetrics.blockedIPs++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'ip_blocked',
|
||||
severity: 'medium',
|
||||
details: `IP ${ip || 'unknown'} blocked: ${reason || 'security policy'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackAuthFailure(username?: string, ip?: string): void {
|
||||
this.securityMetrics.authFailures++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'auth_failure',
|
||||
severity: 'low',
|
||||
details: `Authentication failed for ${username || 'unknown'} from ${ip || 'unknown'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackSpamDetected(sender?: string): void {
|
||||
this.securityMetrics.spamDetected++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'spam_detected',
|
||||
severity: 'low',
|
||||
details: `Spam detected from ${sender || 'unknown'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackMalwareDetected(source?: string): void {
|
||||
this.securityMetrics.malwareDetected++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'malware_detected',
|
||||
severity: 'high',
|
||||
details: `Malware detected from ${source || 'unknown'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackPhishingDetected(source?: string): void {
|
||||
this.securityMetrics.phishingDetected++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'phishing_detected',
|
||||
severity: 'high',
|
||||
details: `Phishing attempt from ${source || 'unknown'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Get network metrics from SmartProxy
|
||||
public async getNetworkStats() {
|
||||
// Use shorter cache TTL for network stats to ensure real-time updates
|
||||
return this.metricsCache.get('networkStats', () => {
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
|
||||
if (!proxyMetrics) {
|
||||
return {
|
||||
connectionsByIP: new Map<string, number>(),
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// Get metrics using the new API
|
||||
const connectionsByIP = proxyMetrics.connections.byIP();
|
||||
const instantThroughput = proxyMetrics.throughput.instant();
|
||||
|
||||
// Get throughput rate
|
||||
const throughputRate = {
|
||||
bytesInPerSecond: instantThroughput.in,
|
||||
bytesOutPerSecond: instantThroughput.out
|
||||
};
|
||||
|
||||
// Get top IPs
|
||||
const topIPs = proxyMetrics.connections.topIPs(10);
|
||||
|
||||
// Get total data transferred
|
||||
const totalDataTransferred = {
|
||||
bytesIn: proxyMetrics.totals.bytesIn(),
|
||||
bytesOut: proxyMetrics.totals.bytesOut()
|
||||
};
|
||||
|
||||
return {
|
||||
connectionsByIP,
|
||||
throughputRate,
|
||||
topIPs,
|
||||
totalDataTransferred,
|
||||
};
|
||||
}, 200); // Use 200ms cache for more frequent updates
|
||||
}
|
||||
}
|
||||
1
ts/monitoring/index.ts
Normal file
1
ts/monitoring/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.metricsmanager.js';
|
||||
@@ -1,956 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Email } from './classes.email.js';
|
||||
import type { IEmailOptions } from './classes.email.js';
|
||||
import { DeliveryStatus } from './classes.emailsendjob.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
import type { IDnsRecord } from './classes.dnsmanager.js';
|
||||
|
||||
/**
|
||||
* Authentication options for API requests
|
||||
*/
|
||||
interface AuthOptions {
|
||||
/** Required API keys for different endpoints */
|
||||
apiKeys: Map<string, string[]>;
|
||||
/** JWT secret for token-based authentication */
|
||||
jwtSecret?: string;
|
||||
/** Whether to validate IP addresses */
|
||||
validateIp?: boolean;
|
||||
/** Allowed IP addresses */
|
||||
allowedIps?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting options for API endpoints
|
||||
*/
|
||||
interface RateLimitOptions {
|
||||
/** Maximum requests per window */
|
||||
maxRequests: number;
|
||||
/** Time window in milliseconds */
|
||||
windowMs: number;
|
||||
/** Whether to apply per endpoint */
|
||||
perEndpoint?: boolean;
|
||||
/** Whether to apply per IP */
|
||||
perIp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* API route definition
|
||||
*/
|
||||
interface ApiRoute {
|
||||
/** HTTP method */
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
/** Path pattern */
|
||||
path: string;
|
||||
/** Handler function */
|
||||
handler: (req: any, res: any) => Promise<any>;
|
||||
/** Required authentication level */
|
||||
authLevel: 'none' | 'basic' | 'admin';
|
||||
/** Rate limiting options */
|
||||
rateLimit?: RateLimitOptions;
|
||||
/** Route description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email send request
|
||||
*/
|
||||
interface SendEmailRequest {
|
||||
/** Email details */
|
||||
email: IEmailOptions;
|
||||
/** Whether to validate domains before sending */
|
||||
validateDomains?: boolean;
|
||||
/** Priority level (1-5, 1 = highest) */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email status response
|
||||
*/
|
||||
interface EmailStatusResponse {
|
||||
/** Email ID */
|
||||
id: string;
|
||||
/** Current status */
|
||||
status: DeliveryStatus;
|
||||
/** Send time */
|
||||
sentAt?: Date;
|
||||
/** Delivery time */
|
||||
deliveredAt?: Date;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Recipient address */
|
||||
recipient: string;
|
||||
/** Number of delivery attempts */
|
||||
attempts: number;
|
||||
/** Next retry time */
|
||||
nextRetry?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain verification response
|
||||
*/
|
||||
interface DomainVerificationResponse {
|
||||
/** Domain name */
|
||||
domain: string;
|
||||
/** Whether the domain is verified */
|
||||
verified: boolean;
|
||||
/** Verification details */
|
||||
details: {
|
||||
/** SPF record status */
|
||||
spf: {
|
||||
valid: boolean;
|
||||
record?: string;
|
||||
error?: string;
|
||||
};
|
||||
/** DKIM record status */
|
||||
dkim: {
|
||||
valid: boolean;
|
||||
record?: string;
|
||||
error?: string;
|
||||
};
|
||||
/** DMARC record status */
|
||||
dmarc: {
|
||||
valid: boolean;
|
||||
record?: string;
|
||||
error?: string;
|
||||
};
|
||||
/** MX record status */
|
||||
mx: {
|
||||
valid: boolean;
|
||||
records?: string[];
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API error response
|
||||
*/
|
||||
interface ApiError {
|
||||
/** Error code */
|
||||
code: string;
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Detailed error information */
|
||||
details?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple HTTP Response helper
|
||||
*/
|
||||
class HttpResponse {
|
||||
private headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
public statusCode: number = 200;
|
||||
|
||||
constructor(private res: any) {}
|
||||
|
||||
header(name: string, value: string): HttpResponse {
|
||||
this.headers[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
status(code: number): HttpResponse {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
json(data: any): void {
|
||||
this.res.writeHead(this.statusCode, this.headers);
|
||||
this.res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.res.writeHead(this.statusCode, this.headers);
|
||||
this.res.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API Manager for MTA service
|
||||
*/
|
||||
export class ApiManager {
|
||||
/** TypedRouter for API routing */
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
/** MTA service reference */
|
||||
private mtaRef: MtaService;
|
||||
/** HTTP server */
|
||||
private server: any;
|
||||
/** Authentication options */
|
||||
private authOptions: AuthOptions;
|
||||
/** API routes */
|
||||
private routes: ApiRoute[] = [];
|
||||
/** Rate limiters */
|
||||
private rateLimiters: Map<string, {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
clients: Map<string, {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
}>;
|
||||
}> = new Map();
|
||||
|
||||
/**
|
||||
* Initialize API Manager
|
||||
* @param mtaRef MTA service reference
|
||||
*/
|
||||
constructor(mtaRef?: MtaService) {
|
||||
this.mtaRef = mtaRef;
|
||||
|
||||
// Default authentication options
|
||||
this.authOptions = {
|
||||
apiKeys: new Map(),
|
||||
validateIp: false,
|
||||
allowedIps: []
|
||||
};
|
||||
|
||||
// Register routes
|
||||
this.registerRoutes();
|
||||
|
||||
// Create HTTP server with request handler
|
||||
this.server = plugins.http.createServer(this.handleRequest.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set MTA service reference
|
||||
* @param mtaRef MTA service reference
|
||||
*/
|
||||
public setMtaService(mtaRef: MtaService): void {
|
||||
this.mtaRef = mtaRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure authentication options
|
||||
* @param options Authentication options
|
||||
*/
|
||||
public configureAuth(options: Partial<AuthOptions>): void {
|
||||
this.authOptions = {
|
||||
...this.authOptions,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP request
|
||||
*/
|
||||
private async handleRequest(req: any, res: any): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
// Create a response helper
|
||||
const response = new HttpResponse(res);
|
||||
|
||||
// Add CORS headers
|
||||
response.header('Access-Control-Allow-Origin', '*');
|
||||
response.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
response.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key');
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if (req.method === 'OPTIONS') {
|
||||
return response.status(200).end();
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse URL to get path and query
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const path = url.pathname;
|
||||
|
||||
// Collect request body if POST or PUT
|
||||
let body = '';
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('error', (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
// Parse body as JSON if Content-Type is application/json
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
req.body = JSON.parse(body);
|
||||
} catch (error) {
|
||||
return response.status(400).json({
|
||||
code: 'INVALID_JSON',
|
||||
message: 'Invalid JSON in request body'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
req.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
// Add authentication level to request
|
||||
req.authLevel = 'none';
|
||||
|
||||
// Check API key
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey) {
|
||||
for (const [level, keys] of this.authOptions.apiKeys.entries()) {
|
||||
if (keys.includes(apiKey)) {
|
||||
req.authLevel = level;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check JWT token (if configured)
|
||||
if (this.authOptions.jwtSecret && req.headers.authorization) {
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
// Note: We would need to add JWT verification
|
||||
// Using a simple placeholder for now
|
||||
const decoded = { level: 'none' }; // Simplified - would use actual JWT library
|
||||
|
||||
if (decoded && decoded.level) {
|
||||
req.authLevel = decoded.level;
|
||||
req.user = decoded;
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid token, but don't fail the request yet
|
||||
console.error('Invalid JWT token:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check IP address (if configured)
|
||||
if (this.authOptions.validateIp) {
|
||||
const clientIp = req.socket.remoteAddress;
|
||||
if (!this.authOptions.allowedIps.includes(clientIp)) {
|
||||
return response.status(403).json({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'IP address not allowed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find matching route
|
||||
const route = this.findRoute(req.method, path);
|
||||
|
||||
if (!route) {
|
||||
return response.status(404).json({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Endpoint not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
if (route.authLevel !== 'none' && req.authLevel !== route.authLevel && req.authLevel !== 'admin') {
|
||||
return response.status(403).json({
|
||||
code: 'FORBIDDEN',
|
||||
message: `This endpoint requires ${route.authLevel} access`
|
||||
});
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
if (route.rateLimit) {
|
||||
const exceeded = this.checkRateLimit(route, req);
|
||||
if (exceeded) {
|
||||
return response.status(429).json({
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: 'Rate limit exceeded, please try again later'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract path parameters
|
||||
const pathParams = this.extractPathParams(route.path, path);
|
||||
req.params = pathParams;
|
||||
|
||||
// Extract query parameters
|
||||
req.query = {};
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
req.query[key] = value;
|
||||
}
|
||||
|
||||
// Handle the request
|
||||
await route.handler(req, response);
|
||||
|
||||
// Log request
|
||||
const duration = Date.now() - start;
|
||||
console.log(`[API] ${req.method} ${path} ${response.statusCode} ${duration}ms`);
|
||||
} catch (error) {
|
||||
console.error(`Error handling request:`, error);
|
||||
|
||||
// Send appropriate error response
|
||||
const status = error.status || 500;
|
||||
const apiError: ApiError = {
|
||||
code: error.code || 'INTERNAL_ERROR',
|
||||
message: error.message || 'Internal server error'
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
apiError.details = error.stack;
|
||||
}
|
||||
|
||||
response.status(status).json(apiError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a route matching the method and path
|
||||
*/
|
||||
private findRoute(method: string, path: string): ApiRoute | null {
|
||||
for (const route of this.routes) {
|
||||
if (route.method === method && this.pathMatches(route.path, path)) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches a route pattern
|
||||
*/
|
||||
private pathMatches(pattern: string, path: string): boolean {
|
||||
// Convert route pattern to regex
|
||||
const patternParts = pattern.split('/');
|
||||
const pathParts = path.split('/');
|
||||
|
||||
if (patternParts.length !== pathParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i].startsWith(':')) {
|
||||
// Parameter - always matches
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternParts[i] !== pathParts[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path parameters from URL
|
||||
*/
|
||||
private extractPathParams(pattern: string, path: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const patternParts = pattern.split('/');
|
||||
const pathParts = path.split('/');
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i].startsWith(':')) {
|
||||
const paramName = patternParts[i].substring(1);
|
||||
params[paramName] = pathParts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register API routes
|
||||
*/
|
||||
private registerRoutes(): void {
|
||||
// Email routes
|
||||
this.addRoute({
|
||||
method: 'POST',
|
||||
path: '/api/email/send',
|
||||
handler: this.handleSendEmail.bind(this),
|
||||
authLevel: 'basic',
|
||||
description: 'Send an email'
|
||||
});
|
||||
|
||||
this.addRoute({
|
||||
method: 'GET',
|
||||
path: '/api/email/status/:id',
|
||||
handler: this.handleGetEmailStatus.bind(this),
|
||||
authLevel: 'basic',
|
||||
description: 'Get email delivery status'
|
||||
});
|
||||
|
||||
// Domain routes
|
||||
this.addRoute({
|
||||
method: 'GET',
|
||||
path: '/api/domain/verify/:domain',
|
||||
handler: this.handleVerifyDomain.bind(this),
|
||||
authLevel: 'basic',
|
||||
description: 'Verify domain DNS records'
|
||||
});
|
||||
|
||||
this.addRoute({
|
||||
method: 'GET',
|
||||
path: '/api/domain/records/:domain',
|
||||
handler: this.handleGetDomainRecords.bind(this),
|
||||
authLevel: 'basic',
|
||||
description: 'Get recommended DNS records for domain'
|
||||
});
|
||||
|
||||
// DKIM routes
|
||||
this.addRoute({
|
||||
method: 'POST',
|
||||
path: '/api/dkim/generate/:domain',
|
||||
handler: this.handleGenerateDkim.bind(this),
|
||||
authLevel: 'admin',
|
||||
description: 'Generate DKIM keys for domain'
|
||||
});
|
||||
|
||||
this.addRoute({
|
||||
method: 'GET',
|
||||
path: '/api/dkim/public/:domain',
|
||||
handler: this.handleGetDkimPublicKey.bind(this),
|
||||
authLevel: 'basic',
|
||||
description: 'Get DKIM public key for domain'
|
||||
});
|
||||
|
||||
// Stats route
|
||||
this.addRoute({
|
||||
method: 'GET',
|
||||
path: '/api/stats',
|
||||
handler: this.handleGetStats.bind(this),
|
||||
authLevel: 'admin',
|
||||
description: 'Get MTA statistics'
|
||||
});
|
||||
|
||||
// Documentation route
|
||||
this.addRoute({
|
||||
method: 'GET',
|
||||
path: '/api',
|
||||
handler: this.handleGetApiDocs.bind(this),
|
||||
authLevel: 'none',
|
||||
description: 'API documentation'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an API route
|
||||
* @param route Route definition
|
||||
*/
|
||||
private addRoute(route: ApiRoute): void {
|
||||
this.routes.push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for a route
|
||||
* @param route Route definition
|
||||
* @param req Express request
|
||||
* @returns Whether rate limit is exceeded
|
||||
*/
|
||||
private checkRateLimit(route: ApiRoute, req: any): boolean {
|
||||
if (!route.rateLimit) return false;
|
||||
|
||||
const { maxRequests, windowMs, perEndpoint, perIp } = route.rateLimit;
|
||||
|
||||
// Determine rate limit key
|
||||
let key = 'global';
|
||||
if (perEndpoint) {
|
||||
key = `${route.method}:${route.path}`;
|
||||
}
|
||||
|
||||
// Get or create limiter
|
||||
if (!this.rateLimiters.has(key)) {
|
||||
this.rateLimiters.set(key, {
|
||||
count: 0,
|
||||
resetTime: Date.now() + windowMs,
|
||||
clients: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
const limiter = this.rateLimiters.get(key);
|
||||
|
||||
// Reset if window has passed
|
||||
if (Date.now() > limiter.resetTime) {
|
||||
limiter.count = 0;
|
||||
limiter.resetTime = Date.now() + windowMs;
|
||||
limiter.clients.clear();
|
||||
}
|
||||
|
||||
// Check per-IP limit if enabled
|
||||
if (perIp) {
|
||||
const clientIp = req.socket.remoteAddress;
|
||||
let clientLimiter = limiter.clients.get(clientIp);
|
||||
|
||||
if (!clientLimiter) {
|
||||
clientLimiter = {
|
||||
count: 0,
|
||||
resetTime: Date.now() + windowMs
|
||||
};
|
||||
limiter.clients.set(clientIp, clientLimiter);
|
||||
}
|
||||
|
||||
// Reset client limiter if needed
|
||||
if (Date.now() > clientLimiter.resetTime) {
|
||||
clientLimiter.count = 0;
|
||||
clientLimiter.resetTime = Date.now() + windowMs;
|
||||
}
|
||||
|
||||
// Check client limit
|
||||
if (clientLimiter.count >= maxRequests) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Increment client count
|
||||
clientLimiter.count++;
|
||||
} else {
|
||||
// Check global limit
|
||||
if (limiter.count >= maxRequests) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Increment global count
|
||||
limiter.count++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API error
|
||||
* @param code Error code
|
||||
* @param message Error message
|
||||
* @param status HTTP status code
|
||||
* @param details Additional details
|
||||
* @returns API error
|
||||
*/
|
||||
private createError(code: string, message: string, status = 400, details?: any): Error & { code: string; status: number; details?: any } {
|
||||
const error = new Error(message) as Error & { code: string; status: number; details?: any };
|
||||
error.code = code;
|
||||
error.status = status;
|
||||
if (details) {
|
||||
error.details = details;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that MTA service is available
|
||||
*/
|
||||
private validateMtaService(): void {
|
||||
if (!this.mtaRef) {
|
||||
throw this.createError('SERVICE_UNAVAILABLE', 'MTA service is not available', 503);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle email send request
|
||||
* @param req Express request
|
||||
* @param res Express response
|
||||
*/
|
||||
private async handleSendEmail(req: any, res: any): Promise<void> {
|
||||
this.validateMtaService();
|
||||
|
||||
const data = req.body as SendEmailRequest;
|
||||
|
||||
if (!data || !data.email) {
|
||||
throw this.createError('INVALID_REQUEST', 'Missing email data');
|
||||
}
|
||||
|
||||
try {
|
||||
// Create Email instance
|
||||
const email = new Email(data.email);
|
||||
|
||||
// Validate domains if requested
|
||||
if (data.validateDomains) {
|
||||
const fromDomain = email.getFromDomain();
|
||||
if (fromDomain) {
|
||||
const verification = await this.mtaRef.dnsManager.verifyEmailAuthRecords(fromDomain);
|
||||
|
||||
// Check if SPF and DKIM are valid
|
||||
if (!verification.spf.valid || !verification.dkim.valid) {
|
||||
throw this.createError('DOMAIN_VERIFICATION_FAILED', 'Domain DNS verification failed', 400, {
|
||||
verification
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send email
|
||||
const id = await this.mtaRef.send(email);
|
||||
|
||||
// Return success response
|
||||
res.json({
|
||||
id,
|
||||
message: 'Email queued successfully',
|
||||
status: 'pending'
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle Email constructor errors
|
||||
if (error.message.includes('Invalid') || error.message.includes('must have')) {
|
||||
throw this.createError('INVALID_EMAIL', error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle email status request
|
||||
* @param req Express request
|
||||
* @param res Express response
|
||||
*/
|
||||
private async handleGetEmailStatus(req: any, res: any): Promise<void> {
|
||||
this.validateMtaService();
|
||||
|
||||
const id = req.params.id;
|
||||
|
||||
if (!id) {
|
||||
throw this.createError('INVALID_REQUEST', 'Missing email ID');
|
||||
}
|
||||
|
||||
// Get email status
|
||||
const status = this.mtaRef.getEmailStatus(id);
|
||||
|
||||
if (!status) {
|
||||
throw this.createError('NOT_FOUND', `Email with ID ${id} not found`, 404);
|
||||
}
|
||||
|
||||
// Create response
|
||||
const response: EmailStatusResponse = {
|
||||
id: status.id,
|
||||
status: status.status,
|
||||
sentAt: status.addedAt,
|
||||
recipient: status.email.to[0],
|
||||
attempts: status.attempts
|
||||
};
|
||||
|
||||
// Add additional fields if available
|
||||
if (status.lastAttempt) {
|
||||
response.sentAt = status.lastAttempt;
|
||||
}
|
||||
|
||||
if (status.status === DeliveryStatus.DELIVERED) {
|
||||
response.deliveredAt = status.lastAttempt;
|
||||
}
|
||||
|
||||
if (status.error) {
|
||||
response.error = status.error.message;
|
||||
}
|
||||
|
||||
if (status.nextAttempt) {
|
||||
response.nextRetry = status.nextAttempt;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle domain verification request
|
||||
* @param req Express request
|
||||
* @param res Express response
|
||||
*/
|
||||
private async handleVerifyDomain(req: any, res: any): Promise<void> {
|
||||
this.validateMtaService();
|
||||
|
||||
const domain = req.params.domain;
|
||||
|
||||
if (!domain) {
|
||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify domain DNS records
|
||||
const records = await this.mtaRef.dnsManager.verifyEmailAuthRecords(domain);
|
||||
|
||||
// Get MX records
|
||||
let mxValid = false;
|
||||
let mxRecords: string[] = [];
|
||||
let mxError: string = undefined;
|
||||
|
||||
try {
|
||||
const mxResult = await this.mtaRef.dnsManager.lookupMx(domain);
|
||||
mxValid = mxResult.length > 0;
|
||||
mxRecords = mxResult.map(mx => mx.exchange);
|
||||
} catch (error) {
|
||||
mxError = error.message;
|
||||
}
|
||||
|
||||
// Create response
|
||||
const response: DomainVerificationResponse = {
|
||||
domain,
|
||||
verified: records.spf.valid && records.dkim.valid && records.dmarc.valid && mxValid,
|
||||
details: {
|
||||
spf: {
|
||||
valid: records.spf.valid,
|
||||
record: records.spf.value,
|
||||
error: records.spf.error
|
||||
},
|
||||
dkim: {
|
||||
valid: records.dkim.valid,
|
||||
record: records.dkim.value,
|
||||
error: records.dkim.error
|
||||
},
|
||||
dmarc: {
|
||||
valid: records.dmarc.valid,
|
||||
record: records.dmarc.value,
|
||||
error: records.dmarc.error
|
||||
},
|
||||
mx: {
|
||||
valid: mxValid,
|
||||
records: mxRecords,
|
||||
error: mxError
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
throw this.createError('VERIFICATION_FAILED', `Domain verification failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get domain records request
|
||||
* @param req Express request
|
||||
* @param res Express response
|
||||
*/
|
||||
private async handleGetDomainRecords(req: any, res: any): Promise<void> {
|
||||
this.validateMtaService();
|
||||
|
||||
const domain = req.params.domain;
|
||||
|
||||
if (!domain) {
|
||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate recommended DNS records
|
||||
const records = await this.mtaRef.dnsManager.generateAllRecommendedRecords(domain);
|
||||
|
||||
res.json({
|
||||
domain,
|
||||
records
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.createError('GENERATION_FAILED', `DNS record generation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generate DKIM keys request
|
||||
* @param req Express request
|
||||
* @param res Express response
|
||||
*/
|
||||
private async handleGenerateDkim(req: any, res: any): Promise<void> {
|
||||
this.validateMtaService();
|
||||
|
||||
const domain = req.params.domain;
|
||||
|
||||
if (!domain) {
|
||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate DKIM keys
|
||||
await this.mtaRef.dkimCreator.handleDKIMKeysForDomain(domain);
|
||||
|
||||
// Get DNS record
|
||||
const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
||||
|
||||
res.json({
|
||||
domain,
|
||||
dnsRecord,
|
||||
message: 'DKIM keys generated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.createError('GENERATION_FAILED', `DKIM generation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get DKIM public key request
|
||||
* @param req Express request
|
||||
* @param res Express response
|
||||
*/
|
||||
private async handleGetDkimPublicKey(req: any, res: any): Promise<void> {
|
||||
this.validateMtaService();
|
||||
|
||||
const domain = req.params.domain;
|
||||
|
||||
if (!domain) {
|
||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get DKIM keys
|
||||
const keys = await this.mtaRef.dkimCreator.readDKIMKeys(domain);
|
||||
|
||||
// Get DNS record
|
||||
const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
||||
|
||||
res.json({
|
||||
domain,
|
||||
publicKey: keys.publicKey,
|
||||
dnsRecord
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.createError('NOT_FOUND', `DKIM keys not found for domain: ${domain}`, 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get stats request
|
||||
* @param req Express request
|
||||
* @param res Express response
|
||||
*/
|
||||
private async handleGetStats(req: any, res: any): Promise<void> {
|
||||
this.validateMtaService();
|
||||
|
||||
// Get MTA stats
|
||||
const stats = this.mtaRef.getStats();
|
||||
|
||||
res.json(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get API docs request
|
||||
* @param req Express request
|
||||
* @param res Express response
|
||||
*/
|
||||
private async handleGetApiDocs(req: any, res: any): Promise<void> {
|
||||
// Generate API documentation
|
||||
const docs = {
|
||||
name: 'MTA API',
|
||||
version: '1.0.0',
|
||||
description: 'API for interacting with the MTA service',
|
||||
endpoints: this.routes.map(route => ({
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
description: route.description,
|
||||
authLevel: route.authLevel
|
||||
}))
|
||||
};
|
||||
|
||||
res.json(docs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the API server
|
||||
* @param port Port to listen on
|
||||
* @returns Promise that resolves when server is started
|
||||
*/
|
||||
public start(port: number = 3000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Start HTTP server
|
||||
this.server.listen(port, () => {
|
||||
console.log(`API server listening on port ${port}`);
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start API server:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the API server
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
console.log('API server stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
|
||||
import { Email } from './classes.email.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
|
||||
const readFile = plugins.util.promisify(plugins.fs.readFile);
|
||||
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
|
||||
const generateKeyPair = plugins.util.promisify(plugins.crypto.generateKeyPair);
|
||||
|
||||
export interface IKeyPaths {
|
||||
privateKeyPath: string;
|
||||
publicKeyPath: string;
|
||||
}
|
||||
|
||||
export class DKIMCreator {
|
||||
private keysDir: string;
|
||||
|
||||
constructor(private metaRef: MtaService, keysDir = paths.keysDir) {
|
||||
this.keysDir = keysDir;
|
||||
}
|
||||
|
||||
public async getKeyPathsForDomain(domainArg: string): Promise<IKeyPaths> {
|
||||
return {
|
||||
privateKeyPath: plugins.path.join(this.keysDir, `${domainArg}-private.pem`),
|
||||
publicKeyPath: plugins.path.join(this.keysDir, `${domainArg}-public.pem`),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if a DKIM key is present and creates one and stores it to disk otherwise
|
||||
public async handleDKIMKeysForDomain(domainArg: string): Promise<void> {
|
||||
try {
|
||||
await this.readDKIMKeys(domainArg);
|
||||
} catch (error) {
|
||||
console.log(`No DKIM keys found for ${domainArg}. Generating...`);
|
||||
await this.createAndStoreDKIMKeys(domainArg);
|
||||
const dnsValue = await this.getDNSRecordForDomain(domainArg);
|
||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
||||
plugins.smartfile.memory.toFsSync(JSON.stringify(dnsValue, null, 2), plugins.path.join(paths.dnsRecordsDir, `${domainArg}.dkimrecord.json`));
|
||||
}
|
||||
}
|
||||
|
||||
public async handleDKIMKeysForEmail(email: Email): Promise<void> {
|
||||
const domain = email.from.split('@')[1];
|
||||
await this.handleDKIMKeysForDomain(domain);
|
||||
}
|
||||
|
||||
// Read DKIM keys from disk
|
||||
public async readDKIMKeys(domainArg: string): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const keyPaths = await this.getKeyPathsForDomain(domainArg);
|
||||
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
|
||||
readFile(keyPaths.privateKeyPath),
|
||||
readFile(keyPaths.publicKeyPath),
|
||||
]);
|
||||
|
||||
// Convert the buffers to strings
|
||||
const privateKey = privateKeyBuffer.toString();
|
||||
const publicKey = publicKeyBuffer.toString();
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Create a DKIM key pair - changed to public for API access
|
||||
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
||||
});
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Store a DKIM key pair to disk - changed to public for API access
|
||||
public async storeDKIMKeys(
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
privateKeyPath: string,
|
||||
publicKeyPath: string
|
||||
): Promise<void> {
|
||||
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
|
||||
}
|
||||
|
||||
// Create a DKIM key pair and store it to disk - changed to public for API access
|
||||
public async createAndStoreDKIMKeys(domain: string): Promise<void> {
|
||||
const { privateKey, publicKey } = await this.createDKIMKeys();
|
||||
const keyPaths = await this.getKeyPathsForDomain(domain);
|
||||
await this.storeDKIMKeys(
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyPaths.privateKeyPath,
|
||||
keyPaths.publicKeyPath
|
||||
);
|
||||
console.log(`DKIM keys for ${domain} created and stored.`);
|
||||
}
|
||||
|
||||
// Changed to public for API access
|
||||
public async getDNSRecordForDomain(domainArg: string): Promise<plugins.tsclass.network.IDnsRecord> {
|
||||
await this.handleDKIMKeysForDomain(domainArg);
|
||||
const keys = await this.readDKIMKeys(domainArg);
|
||||
|
||||
// Remove the PEM header and footer and newlines
|
||||
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
||||
const pemFooter = '-----END PUBLIC KEY-----';
|
||||
const keyContents = keys.publicKey
|
||||
.replace(pemHeader, '')
|
||||
.replace(pemFooter, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
// Now generate the DKIM DNS TXT record
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
||||
|
||||
return {
|
||||
name: `mta._domainkey.${domainArg}`,
|
||||
type: 'TXT',
|
||||
dnsSecEnabled: null,
|
||||
value: dnsRecordValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { MtaService } from './classes.mta.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
|
||||
|
||||
/**
|
||||
* Result of a DKIM verification
|
||||
*/
|
||||
export interface IDkimVerificationResult {
|
||||
isValid: boolean;
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
status?: string;
|
||||
details?: any;
|
||||
errorMessage?: string;
|
||||
signatureFields?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced DKIM verifier using smartmail capabilities
|
||||
*/
|
||||
export class DKIMVerifier {
|
||||
public mtaRef: MtaService;
|
||||
|
||||
// Cache verified results to avoid repeated verification
|
||||
private verificationCache: Map<string, { result: IDkimVerificationResult, timestamp: number }> = new Map();
|
||||
private cacheTtl = 30 * 60 * 1000; // 30 minutes cache
|
||||
|
||||
constructor(mtaRefArg: MtaService) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify DKIM signature for an email
|
||||
* @param emailData The raw email data
|
||||
* @param options Verification options
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verify(
|
||||
emailData: string,
|
||||
options: {
|
||||
useCache?: boolean;
|
||||
returnDetails?: boolean;
|
||||
} = {}
|
||||
): Promise<IDkimVerificationResult> {
|
||||
try {
|
||||
// Generate a cache key from the first 128 bytes of the email data
|
||||
const cacheKey = emailData.slice(0, 128);
|
||||
|
||||
// Check cache if enabled
|
||||
if (options.useCache !== false) {
|
||||
const cached = this.verificationCache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTtl) {
|
||||
logger.log('info', 'DKIM verification result from cache');
|
||||
return cached.result;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to verify using mailauth first
|
||||
try {
|
||||
const verificationMailauth = await plugins.mailauth.authenticate(emailData, {});
|
||||
|
||||
if (verificationMailauth && verificationMailauth.dkim && verificationMailauth.dkim.results.length > 0) {
|
||||
const dkimResult = verificationMailauth.dkim.results[0];
|
||||
const isValid = dkimResult.status.result === 'pass';
|
||||
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid,
|
||||
domain: dkimResult.domain,
|
||||
selector: dkimResult.selector,
|
||||
status: dkimResult.status.result,
|
||||
signatureFields: dkimResult.signature,
|
||||
details: options.returnDetails ? verificationMailauth : undefined
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.log(isValid ? 'info' : 'warn', `DKIM Verification using mailauth: ${isValid ? 'pass' : 'fail'} for domain ${dkimResult.domain}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification ${isValid ? 'passed' : 'failed'} for domain ${dkimResult.domain}`,
|
||||
details: {
|
||||
selector: dkimResult.selector,
|
||||
signatureFields: dkimResult.signature,
|
||||
result: dkimResult.status.result
|
||||
},
|
||||
domain: dkimResult.domain,
|
||||
success: isValid
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (mailauthError) {
|
||||
logger.log('warn', `DKIM verification with mailauth failed, trying smartmail: ${mailauthError.message}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification with mailauth failed, trying smartmail fallback`,
|
||||
details: { error: mailauthError.message },
|
||||
success: false
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to smartmail for verification
|
||||
try {
|
||||
// Parse and extract DKIM signature
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
||||
|
||||
// Find DKIM signature header
|
||||
let dkimSignature = '';
|
||||
if (parsedEmail.headers.has('dkim-signature')) {
|
||||
dkimSignature = parsedEmail.headers.get('dkim-signature') as string;
|
||||
} else {
|
||||
// No DKIM signature found
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: false,
|
||||
errorMessage: 'No DKIM signature found'
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract domain from DKIM signature
|
||||
const domainMatch = dkimSignature.match(/d=([^;]+)/i);
|
||||
const domain = domainMatch ? domainMatch[1].trim() : undefined;
|
||||
|
||||
// Extract selector from DKIM signature
|
||||
const selectorMatch = dkimSignature.match(/s=([^;]+)/i);
|
||||
const selector = selectorMatch ? selectorMatch[1].trim() : undefined;
|
||||
|
||||
// Parse DKIM fields
|
||||
const signatureFields: Record<string, string> = {};
|
||||
const fieldMatches = dkimSignature.matchAll(/([a-z]+)=([^;]+)/gi);
|
||||
for (const match of fieldMatches) {
|
||||
if (match[1] && match[2]) {
|
||||
signatureFields[match[1].toLowerCase()] = match[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Use smartmail's verification if we have domain and selector
|
||||
if (domain && selector) {
|
||||
const dkimKey = await this.fetchDkimKey(domain, selector);
|
||||
|
||||
if (!dkimKey) {
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: false,
|
||||
domain,
|
||||
selector,
|
||||
status: 'permerror',
|
||||
errorMessage: 'DKIM public key not found',
|
||||
signatureFields
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// In a real implementation, we would validate the signature here
|
||||
// For now, if we found a key, we'll consider it valid
|
||||
// In a future update, add actual crypto verification
|
||||
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: true,
|
||||
domain,
|
||||
selector,
|
||||
status: 'pass',
|
||||
signatureFields
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.log('info', `DKIM verification using smartmail: pass for domain ${domain}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification passed for domain ${domain} using fallback verification`,
|
||||
details: {
|
||||
selector,
|
||||
signatureFields
|
||||
},
|
||||
domain,
|
||||
success: true
|
||||
});
|
||||
|
||||
return result;
|
||||
} else {
|
||||
// Missing domain or selector
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: false,
|
||||
domain,
|
||||
selector,
|
||||
status: 'permerror',
|
||||
errorMessage: 'Missing domain or selector in DKIM signature',
|
||||
signatureFields
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.log('warn', `DKIM verification failed: Missing domain or selector in DKIM signature`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification failed: Missing domain or selector in signature`,
|
||||
details: { domain, selector, signatureFields },
|
||||
domain: domain || 'unknown',
|
||||
success: false
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
const result: IDkimVerificationResult = {
|
||||
isValid: false,
|
||||
status: 'temperror',
|
||||
errorMessage: `Verification error: ${error.message}`
|
||||
};
|
||||
|
||||
this.verificationCache.set(cacheKey, {
|
||||
result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.log('error', `DKIM verification error: ${error.message}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification error during processing`,
|
||||
details: { error: error.message },
|
||||
success: false
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `DKIM verification failed with unexpected error: ${error.message}`);
|
||||
|
||||
// Enhanced security logging for unexpected errors
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification failed with unexpected error`,
|
||||
details: { error: error.message },
|
||||
success: false
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
status: 'temperror',
|
||||
errorMessage: `Unexpected verification error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch DKIM public key from DNS
|
||||
* @param domain The domain
|
||||
* @param selector The DKIM selector
|
||||
* @returns The DKIM public key or null if not found
|
||||
*/
|
||||
private async fetchDkimKey(domain: string, selector: string): Promise<string | null> {
|
||||
try {
|
||||
const dkimRecord = `${selector}._domainkey.${domain}`;
|
||||
|
||||
// Use DNS lookup from plugins
|
||||
const txtRecords = await new Promise<string[]>((resolve, reject) => {
|
||||
plugins.dns.resolveTxt(dkimRecord, (err, records) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
||||
resolve([]);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Flatten the arrays that resolveTxt returns
|
||||
resolve(records.map(record => record.join('')));
|
||||
});
|
||||
});
|
||||
|
||||
if (!txtRecords || txtRecords.length === 0) {
|
||||
logger.log('warn', `No DKIM TXT record found for ${dkimRecord}`);
|
||||
|
||||
// Security logging for missing DKIM record
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `No DKIM TXT record found for ${dkimRecord}`,
|
||||
domain,
|
||||
success: false,
|
||||
details: { selector }
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find record matching DKIM format
|
||||
for (const record of txtRecords) {
|
||||
if (record.includes('p=')) {
|
||||
// Extract public key
|
||||
const publicKeyMatch = record.match(/p=([^;]+)/i);
|
||||
if (publicKeyMatch && publicKeyMatch[1]) {
|
||||
return publicKeyMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('warn', `No valid DKIM public key found in TXT records for ${dkimRecord}`);
|
||||
|
||||
// Security logging for invalid DKIM key
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `No valid DKIM public key found in TXT records`,
|
||||
domain,
|
||||
success: false,
|
||||
details: { dkimRecord, selector }
|
||||
});
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error fetching DKIM key: ${error.message}`);
|
||||
|
||||
// Security logging for DKIM key fetch error
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `Error fetching DKIM key for domain`,
|
||||
domain,
|
||||
success: false,
|
||||
details: { error: error.message, selector, dkimRecord: `${selector}._domainkey.${domain}` }
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the verification cache
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.verificationCache.clear();
|
||||
logger.log('info', 'DKIM verification cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of the verification cache
|
||||
* @returns Number of cached items
|
||||
*/
|
||||
public getCacheSize(): number {
|
||||
return this.verificationCache.size;
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
import type { Email } from './classes.email.js';
|
||||
import type { IDnsVerificationResult } from './classes.dnsmanager.js';
|
||||
|
||||
/**
|
||||
* DMARC policy types
|
||||
*/
|
||||
export enum DmarcPolicy {
|
||||
NONE = 'none',
|
||||
QUARANTINE = 'quarantine',
|
||||
REJECT = 'reject'
|
||||
}
|
||||
|
||||
/**
|
||||
* DMARC alignment modes
|
||||
*/
|
||||
export enum DmarcAlignment {
|
||||
RELAXED = 'r',
|
||||
STRICT = 's'
|
||||
}
|
||||
|
||||
/**
|
||||
* DMARC record fields
|
||||
*/
|
||||
export interface DmarcRecord {
|
||||
// Required fields
|
||||
version: string;
|
||||
policy: DmarcPolicy;
|
||||
|
||||
// Optional fields
|
||||
subdomainPolicy?: DmarcPolicy;
|
||||
pct?: number;
|
||||
adkim?: DmarcAlignment;
|
||||
aspf?: DmarcAlignment;
|
||||
reportInterval?: number;
|
||||
failureOptions?: string;
|
||||
reportUriAggregate?: string[];
|
||||
reportUriForensic?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DMARC verification result
|
||||
*/
|
||||
export interface DmarcResult {
|
||||
hasDmarc: boolean;
|
||||
record?: DmarcRecord;
|
||||
spfDomainAligned: boolean;
|
||||
dkimDomainAligned: boolean;
|
||||
spfPassed: boolean;
|
||||
dkimPassed: boolean;
|
||||
policyEvaluated: DmarcPolicy;
|
||||
actualPolicy: DmarcPolicy;
|
||||
appliedPercentage: number;
|
||||
action: 'pass' | 'quarantine' | 'reject';
|
||||
details: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for verifying and enforcing DMARC policies
|
||||
*/
|
||||
export class DmarcVerifier {
|
||||
private mtaRef: MtaService;
|
||||
|
||||
constructor(mtaRefArg: MtaService) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DMARC record from a TXT record string
|
||||
* @param record DMARC TXT record string
|
||||
* @returns Parsed DMARC record or null if invalid
|
||||
*/
|
||||
public parseDmarcRecord(record: string): DmarcRecord | null {
|
||||
if (!record.startsWith('v=DMARC1')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize record with default values
|
||||
const dmarcRecord: DmarcRecord = {
|
||||
version: 'DMARC1',
|
||||
policy: DmarcPolicy.NONE,
|
||||
pct: 100,
|
||||
adkim: DmarcAlignment.RELAXED,
|
||||
aspf: DmarcAlignment.RELAXED
|
||||
};
|
||||
|
||||
// Split the record into tag/value pairs
|
||||
const parts = record.split(';').map(part => part.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || !part.includes('=')) continue;
|
||||
|
||||
const [tag, value] = part.split('=').map(p => p.trim());
|
||||
|
||||
// Process based on tag
|
||||
switch (tag.toLowerCase()) {
|
||||
case 'v':
|
||||
dmarcRecord.version = value;
|
||||
break;
|
||||
case 'p':
|
||||
dmarcRecord.policy = value as DmarcPolicy;
|
||||
break;
|
||||
case 'sp':
|
||||
dmarcRecord.subdomainPolicy = value as DmarcPolicy;
|
||||
break;
|
||||
case 'pct':
|
||||
const pctValue = parseInt(value, 10);
|
||||
if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) {
|
||||
dmarcRecord.pct = pctValue;
|
||||
}
|
||||
break;
|
||||
case 'adkim':
|
||||
dmarcRecord.adkim = value as DmarcAlignment;
|
||||
break;
|
||||
case 'aspf':
|
||||
dmarcRecord.aspf = value as DmarcAlignment;
|
||||
break;
|
||||
case 'ri':
|
||||
const interval = parseInt(value, 10);
|
||||
if (!isNaN(interval) && interval > 0) {
|
||||
dmarcRecord.reportInterval = interval;
|
||||
}
|
||||
break;
|
||||
case 'fo':
|
||||
dmarcRecord.failureOptions = value;
|
||||
break;
|
||||
case 'rua':
|
||||
dmarcRecord.reportUriAggregate = value.split(',').map(uri => {
|
||||
if (uri.startsWith('mailto:')) {
|
||||
return uri.substring(7).trim();
|
||||
}
|
||||
return uri.trim();
|
||||
});
|
||||
break;
|
||||
case 'ruf':
|
||||
dmarcRecord.reportUriForensic = value.split(',').map(uri => {
|
||||
if (uri.startsWith('mailto:')) {
|
||||
return uri.substring(7).trim();
|
||||
}
|
||||
return uri.trim();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure subdomain policy is set if not explicitly provided
|
||||
if (!dmarcRecord.subdomainPolicy) {
|
||||
dmarcRecord.subdomainPolicy = dmarcRecord.policy;
|
||||
}
|
||||
|
||||
return dmarcRecord;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error parsing DMARC record: ${error.message}`, {
|
||||
record,
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if domains are aligned according to DMARC policy
|
||||
* @param headerDomain Domain from header (From)
|
||||
* @param authDomain Domain from authentication (SPF, DKIM)
|
||||
* @param alignment Alignment mode
|
||||
* @returns Whether the domains are aligned
|
||||
*/
|
||||
private isDomainAligned(
|
||||
headerDomain: string,
|
||||
authDomain: string,
|
||||
alignment: DmarcAlignment
|
||||
): boolean {
|
||||
if (!headerDomain || !authDomain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For strict alignment, domains must match exactly
|
||||
if (alignment === DmarcAlignment.STRICT) {
|
||||
return headerDomain.toLowerCase() === authDomain.toLowerCase();
|
||||
}
|
||||
|
||||
// For relaxed alignment, the authenticated domain must be a subdomain of the header domain
|
||||
// or the same as the header domain
|
||||
const headerParts = headerDomain.toLowerCase().split('.');
|
||||
const authParts = authDomain.toLowerCase().split('.');
|
||||
|
||||
// Ensures we have at least two parts (domain and TLD)
|
||||
if (headerParts.length < 2 || authParts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get organizational domain (last two parts)
|
||||
const headerOrgDomain = headerParts.slice(-2).join('.');
|
||||
const authOrgDomain = authParts.slice(-2).join('.');
|
||||
|
||||
return headerOrgDomain === authOrgDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from an email address
|
||||
* @param email Email address
|
||||
* @returns Domain part of the email
|
||||
*/
|
||||
private getDomainFromEmail(email: string): string {
|
||||
if (!email) return '';
|
||||
|
||||
// Handle name + email format: "John Doe <john@example.com>"
|
||||
const matches = email.match(/<([^>]+)>/);
|
||||
const address = matches ? matches[1] : email;
|
||||
|
||||
const parts = address.split('@');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if DMARC verification should be applied based on percentage
|
||||
* @param record DMARC record
|
||||
* @returns Whether DMARC verification should be applied
|
||||
*/
|
||||
private shouldApplyDmarc(record: DmarcRecord): boolean {
|
||||
if (record.pct === undefined || record.pct === 100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Apply DMARC randomly based on percentage
|
||||
const random = Math.floor(Math.random() * 100) + 1;
|
||||
return random <= record.pct;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the action to take based on DMARC policy
|
||||
* @param policy DMARC policy
|
||||
* @returns Action to take
|
||||
*/
|
||||
private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' {
|
||||
switch (policy) {
|
||||
case DmarcPolicy.REJECT:
|
||||
return 'reject';
|
||||
case DmarcPolicy.QUARANTINE:
|
||||
return 'quarantine';
|
||||
case DmarcPolicy.NONE:
|
||||
default:
|
||||
return 'pass';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify DMARC for an incoming email
|
||||
* @param email Email to verify
|
||||
* @param spfResult SPF verification result
|
||||
* @param dkimResult DKIM verification result
|
||||
* @returns DMARC verification result
|
||||
*/
|
||||
public async verify(
|
||||
email: Email,
|
||||
spfResult: { domain: string; result: boolean },
|
||||
dkimResult: { domain: string; result: boolean }
|
||||
): Promise<DmarcResult> {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
|
||||
// Initialize result
|
||||
const result: DmarcResult = {
|
||||
hasDmarc: false,
|
||||
spfDomainAligned: false,
|
||||
dkimDomainAligned: false,
|
||||
spfPassed: spfResult.result,
|
||||
dkimPassed: dkimResult.result,
|
||||
policyEvaluated: DmarcPolicy.NONE,
|
||||
actualPolicy: DmarcPolicy.NONE,
|
||||
appliedPercentage: 100,
|
||||
action: 'pass',
|
||||
details: 'DMARC not configured'
|
||||
};
|
||||
|
||||
try {
|
||||
// Extract From domain
|
||||
const fromHeader = email.getFromEmail();
|
||||
const fromDomain = this.getDomainFromEmail(fromHeader);
|
||||
|
||||
if (!fromDomain) {
|
||||
result.error = 'Invalid From domain';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check alignment
|
||||
result.spfDomainAligned = this.isDomainAligned(
|
||||
fromDomain,
|
||||
spfResult.domain,
|
||||
DmarcAlignment.RELAXED
|
||||
);
|
||||
|
||||
result.dkimDomainAligned = this.isDomainAligned(
|
||||
fromDomain,
|
||||
dkimResult.domain,
|
||||
DmarcAlignment.RELAXED
|
||||
);
|
||||
|
||||
// Lookup DMARC record
|
||||
const dmarcVerificationResult = await this.mtaRef.dnsManager.verifyDmarcRecord(fromDomain);
|
||||
|
||||
// If DMARC record exists and is valid
|
||||
if (dmarcVerificationResult.found && dmarcVerificationResult.valid) {
|
||||
result.hasDmarc = true;
|
||||
|
||||
// Parse DMARC record
|
||||
const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value);
|
||||
|
||||
if (parsedRecord) {
|
||||
result.record = parsedRecord;
|
||||
result.actualPolicy = parsedRecord.policy;
|
||||
result.appliedPercentage = parsedRecord.pct || 100;
|
||||
|
||||
// Override alignment modes if specified in record
|
||||
if (parsedRecord.adkim) {
|
||||
result.dkimDomainAligned = this.isDomainAligned(
|
||||
fromDomain,
|
||||
dkimResult.domain,
|
||||
parsedRecord.adkim
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedRecord.aspf) {
|
||||
result.spfDomainAligned = this.isDomainAligned(
|
||||
fromDomain,
|
||||
spfResult.domain,
|
||||
parsedRecord.aspf
|
||||
);
|
||||
}
|
||||
|
||||
// Determine DMARC compliance
|
||||
const spfAligned = result.spfPassed && result.spfDomainAligned;
|
||||
const dkimAligned = result.dkimPassed && result.dkimDomainAligned;
|
||||
|
||||
// Email passes DMARC if either SPF or DKIM passes with alignment
|
||||
const dmarcPass = spfAligned || dkimAligned;
|
||||
|
||||
// Use record percentage to determine if policy should be applied
|
||||
const applyPolicy = this.shouldApplyDmarc(parsedRecord);
|
||||
|
||||
if (!dmarcPass) {
|
||||
// DMARC failed, apply policy
|
||||
result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE;
|
||||
result.action = this.determineAction(result.policyEvaluated);
|
||||
result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`;
|
||||
} else {
|
||||
result.policyEvaluated = DmarcPolicy.NONE;
|
||||
result.action = 'pass';
|
||||
result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`;
|
||||
}
|
||||
} else {
|
||||
result.error = 'Invalid DMARC record format';
|
||||
result.details = 'DMARC record invalid';
|
||||
}
|
||||
} else {
|
||||
// No DMARC record found or invalid
|
||||
result.details = dmarcVerificationResult.error || 'No DMARC record found';
|
||||
}
|
||||
|
||||
// Log the DMARC verification
|
||||
securityLogger.logEvent({
|
||||
level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DMARC,
|
||||
message: result.details,
|
||||
domain: fromDomain,
|
||||
details: {
|
||||
fromDomain,
|
||||
spfDomain: spfResult.domain,
|
||||
dkimDomain: dkimResult.domain,
|
||||
spfPassed: result.spfPassed,
|
||||
dkimPassed: result.dkimPassed,
|
||||
spfAligned: result.spfDomainAligned,
|
||||
dkimAligned: result.dkimDomainAligned,
|
||||
dmarcPolicy: result.policyEvaluated,
|
||||
action: result.action
|
||||
},
|
||||
success: result.action === 'pass'
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error verifying DMARC: ${error.message}`, {
|
||||
error: error.message,
|
||||
emailId: email.getMessageId()
|
||||
});
|
||||
|
||||
result.error = `DMARC verification error: ${error.message}`;
|
||||
|
||||
// Log error
|
||||
securityLogger.logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.DMARC,
|
||||
message: `DMARC verification failed with error`,
|
||||
details: {
|
||||
error: error.message,
|
||||
emailId: email.getMessageId()
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply DMARC policy to an email
|
||||
* @param email Email to apply policy to
|
||||
* @param dmarcResult DMARC verification result
|
||||
* @returns Whether the email should be accepted
|
||||
*/
|
||||
public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean {
|
||||
// Apply action based on DMARC verification result
|
||||
switch (dmarcResult.action) {
|
||||
case 'reject':
|
||||
// Reject the email
|
||||
email.mightBeSpam = true;
|
||||
logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, {
|
||||
emailId: email.getMessageId(),
|
||||
from: email.getFromEmail(),
|
||||
subject: email.subject
|
||||
});
|
||||
return false;
|
||||
|
||||
case 'quarantine':
|
||||
// Quarantine the email (mark as spam)
|
||||
email.mightBeSpam = true;
|
||||
|
||||
// Add spam header
|
||||
if (!email.headers['X-Spam-Flag']) {
|
||||
email.headers['X-Spam-Flag'] = 'YES';
|
||||
}
|
||||
|
||||
// Add DMARC reason header
|
||||
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
||||
|
||||
logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, {
|
||||
emailId: email.getMessageId(),
|
||||
from: email.getFromEmail(),
|
||||
subject: email.subject
|
||||
});
|
||||
return true;
|
||||
|
||||
case 'pass':
|
||||
default:
|
||||
// Accept the email
|
||||
// Add DMARC result header for information
|
||||
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end DMARC verification and policy application
|
||||
* This method should be called after SPF and DKIM verification
|
||||
* @param email Email to verify
|
||||
* @param spfResult SPF verification result
|
||||
* @param dkimResult DKIM verification result
|
||||
* @returns Whether the email should be accepted
|
||||
*/
|
||||
public async verifyAndApply(
|
||||
email: Email,
|
||||
spfResult: { domain: string; result: boolean },
|
||||
dkimResult: { domain: string; result: boolean }
|
||||
): Promise<boolean> {
|
||||
// Verify DMARC
|
||||
const dmarcResult = await this.verify(email, spfResult, dkimResult);
|
||||
|
||||
// Apply DMARC policy
|
||||
return this.applyPolicy(email, dmarcResult);
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
|
||||
/**
|
||||
* Interface for DNS record information
|
||||
*/
|
||||
export interface IDnsRecord {
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
dnsSecEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for DNS lookup options
|
||||
*/
|
||||
export interface IDnsLookupOptions {
|
||||
/** Cache time to live in milliseconds, 0 to disable caching */
|
||||
cacheTtl?: number;
|
||||
/** Timeout for DNS queries in milliseconds */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for DNS verification result
|
||||
*/
|
||||
export interface IDnsVerificationResult {
|
||||
record: string;
|
||||
found: boolean;
|
||||
valid: boolean;
|
||||
value?: string;
|
||||
expectedValue?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager for DNS-related operations, including record lookups, verification, and generation
|
||||
*/
|
||||
export class DNSManager {
|
||||
public mtaRef: MtaService;
|
||||
private cache: Map<string, { data: any; expires: number }> = new Map();
|
||||
private defaultOptions: IDnsLookupOptions = {
|
||||
cacheTtl: 300000, // 5 minutes
|
||||
timeout: 5000 // 5 seconds
|
||||
};
|
||||
|
||||
constructor(mtaRefArg: MtaService, options?: IDnsLookupOptions) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
|
||||
if (options) {
|
||||
this.defaultOptions = {
|
||||
...this.defaultOptions,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure the DNS records directory exists
|
||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup MX records for a domain
|
||||
* @param domain Domain to look up
|
||||
* @param options Lookup options
|
||||
* @returns Array of MX records sorted by priority
|
||||
*/
|
||||
public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise<plugins.dns.MxRecord[]> {
|
||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
||||
const cacheKey = `mx:${domain}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = this.getFromCache<plugins.dns.MxRecord[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await this.dnsResolveMx(domain, lookupOptions.timeout);
|
||||
|
||||
// Sort by priority
|
||||
records.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Cache the result
|
||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error(`Error looking up MX records for ${domain}:`, error);
|
||||
throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup TXT records for a domain
|
||||
* @param domain Domain to look up
|
||||
* @param options Lookup options
|
||||
* @returns Array of TXT records
|
||||
*/
|
||||
public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise<string[][]> {
|
||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
||||
const cacheKey = `txt:${domain}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = this.getFromCache<string[][]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await this.dnsResolveTxt(domain, lookupOptions.timeout);
|
||||
|
||||
// Cache the result
|
||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error(`Error looking up TXT records for ${domain}:`, error);
|
||||
throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find specific TXT record by subdomain and prefix
|
||||
* @param domain Base domain
|
||||
* @param subdomain Subdomain prefix (e.g., "dkim._domainkey")
|
||||
* @param prefix Record prefix to match (e.g., "v=DKIM1")
|
||||
* @param options Lookup options
|
||||
* @returns Matching TXT record or null if not found
|
||||
*/
|
||||
public async findTxtRecord(
|
||||
domain: string,
|
||||
subdomain: string = '',
|
||||
prefix: string = '',
|
||||
options?: IDnsLookupOptions
|
||||
): Promise<string | null> {
|
||||
const fullDomain = subdomain ? `${subdomain}.${domain}` : domain;
|
||||
|
||||
try {
|
||||
const records = await this.lookupTxt(fullDomain, options);
|
||||
|
||||
for (const recordArray of records) {
|
||||
// TXT records can be split into chunks, join them
|
||||
const record = recordArray.join('');
|
||||
|
||||
if (!prefix || record.startsWith(prefix)) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Domain might not exist or no TXT records
|
||||
console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid SPF record
|
||||
* @param domain Domain to verify
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifySpfRecord(domain: string): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'SPF',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1');
|
||||
|
||||
if (spfRecord) {
|
||||
result.found = true;
|
||||
result.value = spfRecord;
|
||||
|
||||
// Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms
|
||||
const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord);
|
||||
result.valid = isValid;
|
||||
|
||||
if (!isValid) {
|
||||
result.error = 'SPF record format is invalid';
|
||||
}
|
||||
} else {
|
||||
result.error = 'No SPF record found';
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying SPF: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid DKIM record
|
||||
* @param domain Domain to verify
|
||||
* @param selector DKIM selector (usually "mta" in our case)
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'DKIM',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const dkimSelector = `${selector}._domainkey`;
|
||||
const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1');
|
||||
|
||||
if (dkimRecord) {
|
||||
result.found = true;
|
||||
result.value = dkimRecord;
|
||||
|
||||
// Basic validation - check for required fields
|
||||
const hasP = dkimRecord.includes('p=');
|
||||
result.valid = dkimRecord.includes('v=DKIM1') && hasP;
|
||||
|
||||
if (!result.valid) {
|
||||
result.error = 'DKIM record is missing required fields';
|
||||
} else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) {
|
||||
result.valid = false;
|
||||
result.error = 'DKIM record has invalid public key format';
|
||||
}
|
||||
} else {
|
||||
result.error = `No DKIM record found for selector ${selector}`;
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying DKIM: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid DMARC record
|
||||
* @param domain Domain to verify
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifyDmarcRecord(domain: string): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'DMARC',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const dmarcDomain = `_dmarc.${domain}`;
|
||||
const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1');
|
||||
|
||||
if (dmarcRecord) {
|
||||
result.found = true;
|
||||
result.value = dmarcRecord;
|
||||
|
||||
// Basic validation - check for required fields
|
||||
const hasPolicy = dmarcRecord.includes('p=');
|
||||
result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy;
|
||||
|
||||
if (!result.valid) {
|
||||
result.error = 'DMARC record is missing required fields';
|
||||
}
|
||||
} else {
|
||||
result.error = 'No DMARC record found';
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying DMARC: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all email authentication records (SPF, DKIM, DMARC) for a domain
|
||||
* @param domain Domain to check
|
||||
* @param dkimSelector DKIM selector
|
||||
* @returns Object with verification results for each record type
|
||||
*/
|
||||
public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{
|
||||
spf: IDnsVerificationResult;
|
||||
dkim: IDnsVerificationResult;
|
||||
dmarc: IDnsVerificationResult;
|
||||
}> {
|
||||
const [spf, dkim, dmarc] = await Promise.all([
|
||||
this.verifySpfRecord(domain),
|
||||
this.verifyDkimRecord(domain, dkimSelector),
|
||||
this.verifyDmarcRecord(domain)
|
||||
]);
|
||||
|
||||
return { spf, dkim, dmarc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a recommended SPF record for a domain
|
||||
* @param domain Domain name
|
||||
* @param options Configuration options for the SPF record
|
||||
* @returns Generated SPF record
|
||||
*/
|
||||
public generateSpfRecord(domain: string, options: {
|
||||
includeMx?: boolean;
|
||||
includeA?: boolean;
|
||||
includeIps?: string[];
|
||||
includeSpf?: string[];
|
||||
policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject';
|
||||
} = {}): IDnsRecord {
|
||||
const {
|
||||
includeMx = true,
|
||||
includeA = true,
|
||||
includeIps = [],
|
||||
includeSpf = [],
|
||||
policy = 'softfail'
|
||||
} = options;
|
||||
|
||||
let value = 'v=spf1';
|
||||
|
||||
if (includeMx) {
|
||||
value += ' mx';
|
||||
}
|
||||
|
||||
if (includeA) {
|
||||
value += ' a';
|
||||
}
|
||||
|
||||
// Add IP addresses
|
||||
for (const ip of includeIps) {
|
||||
if (ip.includes(':')) {
|
||||
value += ` ip6:${ip}`;
|
||||
} else {
|
||||
value += ` ip4:${ip}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add includes
|
||||
for (const include of includeSpf) {
|
||||
value += ` include:${include}`;
|
||||
}
|
||||
|
||||
// Add policy
|
||||
const policyMap = {
|
||||
'none': '?all',
|
||||
'neutral': '~all',
|
||||
'softfail': '~all',
|
||||
'fail': '-all',
|
||||
'reject': '-all'
|
||||
};
|
||||
|
||||
value += ` ${policyMap[policy]}`;
|
||||
|
||||
return {
|
||||
name: domain,
|
||||
type: 'TXT',
|
||||
value: value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a recommended DMARC record for a domain
|
||||
* @param domain Domain name
|
||||
* @param options Configuration options for the DMARC record
|
||||
* @returns Generated DMARC record
|
||||
*/
|
||||
public generateDmarcRecord(domain: string, options: {
|
||||
policy?: 'none' | 'quarantine' | 'reject';
|
||||
subdomainPolicy?: 'none' | 'quarantine' | 'reject';
|
||||
pct?: number;
|
||||
rua?: string;
|
||||
ruf?: string;
|
||||
daysInterval?: number;
|
||||
} = {}): IDnsRecord {
|
||||
const {
|
||||
policy = 'none',
|
||||
subdomainPolicy,
|
||||
pct = 100,
|
||||
rua,
|
||||
ruf,
|
||||
daysInterval = 1
|
||||
} = options;
|
||||
|
||||
let value = 'v=DMARC1; p=' + policy;
|
||||
|
||||
if (subdomainPolicy) {
|
||||
value += `; sp=${subdomainPolicy}`;
|
||||
}
|
||||
|
||||
if (pct !== 100) {
|
||||
value += `; pct=${pct}`;
|
||||
}
|
||||
|
||||
if (rua) {
|
||||
value += `; rua=mailto:${rua}`;
|
||||
}
|
||||
|
||||
if (ruf) {
|
||||
value += `; ruf=mailto:${ruf}`;
|
||||
}
|
||||
|
||||
if (daysInterval !== 1) {
|
||||
value += `; ri=${daysInterval * 86400}`;
|
||||
}
|
||||
|
||||
// Add reporting format and ADKIM/ASPF alignment
|
||||
value += '; fo=1; adkim=r; aspf=r';
|
||||
|
||||
return {
|
||||
name: `_dmarc.${domain}`,
|
||||
type: 'TXT',
|
||||
value: value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save DNS record recommendations to a file
|
||||
* @param domain Domain name
|
||||
* @param records DNS records to save
|
||||
*/
|
||||
public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise<void> {
|
||||
try {
|
||||
const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`);
|
||||
plugins.smartfile.memory.toFsSync(JSON.stringify(records, null, 2), filePath);
|
||||
console.log(`DNS recommendations for ${domain} saved to ${filePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Error saving DNS recommendations for ${domain}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key value
|
||||
* @param key Cache key
|
||||
* @returns Cached value or undefined if not found or expired
|
||||
*/
|
||||
private getFromCache<T>(key: string): T | undefined {
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
// Remove expired entry
|
||||
if (cached) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache key value
|
||||
* @param key Cache key
|
||||
* @param data Data to cache
|
||||
* @param ttl TTL in milliseconds
|
||||
*/
|
||||
private setInCache<T>(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void {
|
||||
if (ttl <= 0) return; // Don't cache if TTL is disabled
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
expires: Date.now() + ttl
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the DNS cache
|
||||
* @param key Optional specific key to clear, or all cache if not provided
|
||||
*/
|
||||
public clearCache(key?: string): void {
|
||||
if (key) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based wrapper for dns.resolveMx
|
||||
* @param domain Domain to resolve
|
||||
* @param timeout Timeout in milliseconds
|
||||
* @returns Promise resolving to MX records
|
||||
*/
|
||||
private dnsResolveMx(domain: string, timeout: number = 5000): Promise<plugins.dns.MxRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`DNS MX lookup timeout for ${domain}`));
|
||||
}, timeout);
|
||||
|
||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(addresses);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based wrapper for dns.resolveTxt
|
||||
* @param domain Domain to resolve
|
||||
* @param timeout Timeout in milliseconds
|
||||
* @returns Promise resolving to TXT records
|
||||
*/
|
||||
private dnsResolveTxt(domain: string, timeout: number = 5000): Promise<string[][]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`DNS TXT lookup timeout for ${domain}`));
|
||||
}, timeout);
|
||||
|
||||
plugins.dns.resolveTxt(domain, (err, records) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(records);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all recommended DNS records for proper email authentication
|
||||
* @param domain Domain to generate records for
|
||||
* @returns Array of recommended DNS records
|
||||
*/
|
||||
public async generateAllRecommendedRecords(domain: string): Promise<IDnsRecord[]> {
|
||||
const records: IDnsRecord[] = [];
|
||||
|
||||
// Get DKIM record (already created by DKIMCreator)
|
||||
try {
|
||||
// Now using the public method
|
||||
const dkimRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
||||
records.push(dkimRecord);
|
||||
} catch (error) {
|
||||
console.error(`Error getting DKIM record for ${domain}:`, error);
|
||||
}
|
||||
|
||||
// Generate SPF record
|
||||
const spfRecord = this.generateSpfRecord(domain, {
|
||||
includeMx: true,
|
||||
includeA: true,
|
||||
policy: 'softfail'
|
||||
});
|
||||
records.push(spfRecord);
|
||||
|
||||
// Generate DMARC record
|
||||
const dmarcRecord = this.generateDmarcRecord(domain, {
|
||||
policy: 'none', // Start with monitoring mode
|
||||
rua: `dmarc@${domain}` // Replace with appropriate report address
|
||||
});
|
||||
records.push(dmarcRecord);
|
||||
|
||||
// Save recommendations
|
||||
await this.saveDnsRecommendations(domain, records);
|
||||
|
||||
return records;
|
||||
}
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EmailValidator } from '../email/classes.emailvalidator.js';
|
||||
|
||||
export interface IAttachment {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
contentId?: string; // Optional content ID for inline attachments
|
||||
encoding?: string; // Optional encoding specification
|
||||
}
|
||||
|
||||
export interface IEmailOptions {
|
||||
from: string;
|
||||
to: string | string[]; // Support multiple recipients
|
||||
cc?: string | string[]; // Optional CC recipients
|
||||
bcc?: string | string[]; // Optional BCC recipients
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string; // Optional HTML version
|
||||
attachments?: IAttachment[];
|
||||
headers?: Record<string, string>; // Optional additional headers
|
||||
mightBeSpam?: boolean;
|
||||
priority?: 'high' | 'normal' | 'low'; // Optional email priority
|
||||
skipAdvancedValidation?: boolean; // Skip advanced validation for special cases
|
||||
variables?: Record<string, any>; // Template variables for placeholder replacement
|
||||
}
|
||||
|
||||
export class Email {
|
||||
from: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
attachments: IAttachment[];
|
||||
headers: Record<string, string>;
|
||||
mightBeSpam: boolean;
|
||||
priority: 'high' | 'normal' | 'low';
|
||||
variables: Record<string, any>;
|
||||
private envelopeFrom: string;
|
||||
private messageId: string;
|
||||
|
||||
// Static validator instance for reuse
|
||||
private static emailValidator: EmailValidator;
|
||||
|
||||
constructor(options: IEmailOptions) {
|
||||
// Initialize validator if not already
|
||||
if (!Email.emailValidator) {
|
||||
Email.emailValidator = new EmailValidator();
|
||||
}
|
||||
|
||||
// Validate and set the from address using improved validation
|
||||
if (!this.isValidEmail(options.from)) {
|
||||
throw new Error(`Invalid sender email address: ${options.from}`);
|
||||
}
|
||||
this.from = options.from;
|
||||
|
||||
// Handle to addresses (single or multiple)
|
||||
this.to = this.parseRecipients(options.to);
|
||||
|
||||
// Handle optional cc and bcc
|
||||
this.cc = options.cc ? this.parseRecipients(options.cc) : [];
|
||||
this.bcc = options.bcc ? this.parseRecipients(options.bcc) : [];
|
||||
|
||||
// Validate that we have at least one recipient
|
||||
if (this.to.length === 0 && this.cc.length === 0 && this.bcc.length === 0) {
|
||||
throw new Error('Email must have at least one recipient');
|
||||
}
|
||||
|
||||
// Set subject with sanitization
|
||||
this.subject = this.sanitizeString(options.subject || '');
|
||||
|
||||
// Set text content with sanitization
|
||||
this.text = this.sanitizeString(options.text || '');
|
||||
|
||||
// Set optional HTML content
|
||||
this.html = options.html ? this.sanitizeString(options.html) : undefined;
|
||||
|
||||
// Set attachments
|
||||
this.attachments = Array.isArray(options.attachments) ? options.attachments : [];
|
||||
|
||||
// Set additional headers
|
||||
this.headers = options.headers || {};
|
||||
|
||||
// Set spam flag
|
||||
this.mightBeSpam = options.mightBeSpam || false;
|
||||
|
||||
// Set priority
|
||||
this.priority = options.priority || 'normal';
|
||||
|
||||
// Set template variables
|
||||
this.variables = options.variables || {};
|
||||
|
||||
// Initialize envelope from (defaults to the from address)
|
||||
this.envelopeFrom = this.from;
|
||||
|
||||
// Generate message ID if not provided
|
||||
this.messageId = `<${Date.now()}.${Math.random().toString(36).substring(2, 15)}@${this.getFromDomain() || 'localhost'}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address using smartmail's EmailAddressValidator
|
||||
* For constructor validation, we only check syntax to avoid delays
|
||||
*
|
||||
* @param email The email address to validate
|
||||
* @returns boolean indicating if the email is valid
|
||||
*/
|
||||
private isValidEmail(email: string): boolean {
|
||||
if (!email || typeof email !== 'string') return false;
|
||||
|
||||
// Use smartmail's validation for better accuracy
|
||||
return Email.emailValidator.isValidFormat(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates recipient email addresses
|
||||
* @param recipients A string or array of recipient emails
|
||||
* @returns Array of validated email addresses
|
||||
*/
|
||||
private parseRecipients(recipients: string | string[]): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
if (typeof recipients === 'string') {
|
||||
// Handle single recipient
|
||||
if (this.isValidEmail(recipients)) {
|
||||
result.push(recipients);
|
||||
} else {
|
||||
throw new Error(`Invalid recipient email address: ${recipients}`);
|
||||
}
|
||||
} else if (Array.isArray(recipients)) {
|
||||
// Handle multiple recipients
|
||||
for (const recipient of recipients) {
|
||||
if (this.isValidEmail(recipient)) {
|
||||
result.push(recipient);
|
||||
} else {
|
||||
throw new Error(`Invalid recipient email address: ${recipient}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic sanitization for strings to prevent header injection
|
||||
* @param input The string to sanitize
|
||||
* @returns Sanitized string
|
||||
*/
|
||||
private sanitizeString(input: string): string {
|
||||
if (!input) return '';
|
||||
|
||||
// Remove CR and LF characters to prevent header injection
|
||||
return input.replace(/\r|\n/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the domain part of the from email address
|
||||
* @returns The domain part of the from email or null if invalid
|
||||
*/
|
||||
public getFromDomain(): string | null {
|
||||
try {
|
||||
const parts = this.from.split('@');
|
||||
if (parts.length !== 2 || !parts[1]) {
|
||||
return null;
|
||||
}
|
||||
return parts[1];
|
||||
} catch (error) {
|
||||
console.error('Error extracting domain from email:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all recipients (to, cc, bcc) as a unique array
|
||||
* @returns Array of all unique recipient email addresses
|
||||
*/
|
||||
public getAllRecipients(): string[] {
|
||||
// Combine all recipients and remove duplicates
|
||||
return [...new Set([...this.to, ...this.cc, ...this.bcc])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets primary recipient (first in the to field)
|
||||
* @returns The primary recipient email or null if none exists
|
||||
*/
|
||||
public getPrimaryRecipient(): string | null {
|
||||
return this.to.length > 0 ? this.to[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the email has attachments
|
||||
* @returns Boolean indicating if the email has attachments
|
||||
*/
|
||||
public hasAttachments(): boolean {
|
||||
return this.attachments.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a recipient to the email
|
||||
* @param email The recipient email address
|
||||
* @param type The recipient type (to, cc, bcc)
|
||||
* @returns This instance for method chaining
|
||||
*/
|
||||
public addRecipient(
|
||||
email: string,
|
||||
type: 'to' | 'cc' | 'bcc' = 'to'
|
||||
): this {
|
||||
if (!this.isValidEmail(email)) {
|
||||
throw new Error(`Invalid recipient email address: ${email}`);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'to':
|
||||
if (!this.to.includes(email)) {
|
||||
this.to.push(email);
|
||||
}
|
||||
break;
|
||||
case 'cc':
|
||||
if (!this.cc.includes(email)) {
|
||||
this.cc.push(email);
|
||||
}
|
||||
break;
|
||||
case 'bcc':
|
||||
if (!this.bcc.includes(email)) {
|
||||
this.bcc.push(email);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an attachment to the email
|
||||
* @param attachment The attachment to add
|
||||
* @returns This instance for method chaining
|
||||
*/
|
||||
public addAttachment(attachment: IAttachment): this {
|
||||
this.attachments.push(attachment);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom header to the email
|
||||
* @param name The header name
|
||||
* @param value The header value
|
||||
* @returns This instance for method chaining
|
||||
*/
|
||||
public addHeader(name: string, value: string): this {
|
||||
this.headers[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the email priority
|
||||
* @param priority The priority level
|
||||
* @returns This instance for method chaining
|
||||
*/
|
||||
public setPriority(priority: 'high' | 'normal' | 'low'): this {
|
||||
this.priority = priority;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a template variable
|
||||
* @param key The variable key
|
||||
* @param value The variable value
|
||||
* @returns This instance for method chaining
|
||||
*/
|
||||
public setVariable(key: string, value: any): this {
|
||||
this.variables[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple template variables at once
|
||||
* @param variables The variables object
|
||||
* @returns This instance for method chaining
|
||||
*/
|
||||
public setVariables(variables: Record<string, any>): this {
|
||||
this.variables = { ...this.variables, ...variables };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subject with variables applied
|
||||
* @param variables Optional additional variables to apply
|
||||
* @returns The processed subject
|
||||
*/
|
||||
public getSubjectWithVariables(variables?: Record<string, any>): string {
|
||||
return this.applyVariables(this.subject, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text content with variables applied
|
||||
* @param variables Optional additional variables to apply
|
||||
* @returns The processed text content
|
||||
*/
|
||||
public getTextWithVariables(variables?: Record<string, any>): string {
|
||||
return this.applyVariables(this.text, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTML content with variables applied
|
||||
* @param variables Optional additional variables to apply
|
||||
* @returns The processed HTML content or undefined if none
|
||||
*/
|
||||
public getHtmlWithVariables(variables?: Record<string, any>): string | undefined {
|
||||
return this.html ? this.applyVariables(this.html, variables) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply template variables to a string
|
||||
* @param template The template string
|
||||
* @param additionalVariables Optional additional variables to apply
|
||||
* @returns The processed string
|
||||
*/
|
||||
private applyVariables(template: string, additionalVariables?: Record<string, any>): string {
|
||||
// If no template or variables, return as is
|
||||
if (!template || (!Object.keys(this.variables).length && !additionalVariables)) {
|
||||
return template;
|
||||
}
|
||||
|
||||
// Combine instance variables with additional ones
|
||||
const allVariables = { ...this.variables, ...additionalVariables };
|
||||
|
||||
// Simple variable replacement
|
||||
return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
|
||||
const trimmedKey = key.trim();
|
||||
return allVariables[trimmedKey] !== undefined ? String(allVariables[trimmedKey]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total size of all attachments in bytes
|
||||
* @returns Total size of all attachments in bytes
|
||||
*/
|
||||
public getAttachmentsSize(): number {
|
||||
return this.attachments.reduce((total, attachment) => {
|
||||
return total + (attachment.content?.length || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform advanced validation on sender and recipient email addresses
|
||||
* This should be called separately after instantiation when ready to check MX records
|
||||
* @param options Validation options
|
||||
* @returns Promise resolving to validation results for all addresses
|
||||
*/
|
||||
public async validateAddresses(options: {
|
||||
checkMx?: boolean;
|
||||
checkDisposable?: boolean;
|
||||
checkSenderOnly?: boolean;
|
||||
checkFirstRecipientOnly?: boolean;
|
||||
} = {}): Promise<{
|
||||
sender: { email: string; result: any };
|
||||
recipients: Array<{ email: string; result: any }>;
|
||||
isValid: boolean;
|
||||
}> {
|
||||
const result = {
|
||||
sender: { email: this.from, result: null },
|
||||
recipients: [],
|
||||
isValid: true
|
||||
};
|
||||
|
||||
// Validate sender
|
||||
result.sender.result = await Email.emailValidator.validate(this.from, {
|
||||
checkMx: options.checkMx !== false,
|
||||
checkDisposable: options.checkDisposable !== false
|
||||
});
|
||||
|
||||
// If sender fails validation, the whole email is considered invalid
|
||||
if (!result.sender.result.isValid) {
|
||||
result.isValid = false;
|
||||
}
|
||||
|
||||
// If we're only checking the sender, return early
|
||||
if (options.checkSenderOnly) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate recipients
|
||||
const recipientsToCheck = options.checkFirstRecipientOnly ?
|
||||
[this.to[0]] : this.getAllRecipients();
|
||||
|
||||
for (const recipient of recipientsToCheck) {
|
||||
const recipientResult = await Email.emailValidator.validate(recipient, {
|
||||
checkMx: options.checkMx !== false,
|
||||
checkDisposable: options.checkDisposable !== false
|
||||
});
|
||||
|
||||
result.recipients.push({
|
||||
email: recipient,
|
||||
result: recipientResult
|
||||
});
|
||||
|
||||
// If any recipient fails validation, mark the whole email as invalid
|
||||
if (!recipientResult.isValid) {
|
||||
result.isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this email to a smartmail instance
|
||||
* @returns A new Smartmail instance
|
||||
*/
|
||||
public async toSmartmail(): Promise<plugins.smartmail.Smartmail<any>> {
|
||||
const smartmail = new plugins.smartmail.Smartmail({
|
||||
from: this.from,
|
||||
subject: this.subject,
|
||||
body: this.html || this.text
|
||||
});
|
||||
|
||||
// Add recipients - ensure we're using the correct format
|
||||
// (newer version of smartmail expects objects with email property)
|
||||
for (const recipient of this.to) {
|
||||
// Use the proper addRecipient method for the current smartmail version
|
||||
if (typeof smartmail.addRecipient === 'function') {
|
||||
smartmail.addRecipient(recipient);
|
||||
} else {
|
||||
// Fallback for older versions or different interface
|
||||
(smartmail.options.to as any[]).push({
|
||||
email: recipient,
|
||||
name: recipient.split('@')[0] // Simple name extraction
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle CC recipients
|
||||
for (const ccRecipient of this.cc) {
|
||||
if (typeof smartmail.addRecipient === 'function') {
|
||||
smartmail.addRecipient(ccRecipient, 'cc');
|
||||
} else {
|
||||
// Fallback for older versions
|
||||
if (!smartmail.options.cc) smartmail.options.cc = [];
|
||||
(smartmail.options.cc as any[]).push({
|
||||
email: ccRecipient,
|
||||
name: ccRecipient.split('@')[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle BCC recipients
|
||||
for (const bccRecipient of this.bcc) {
|
||||
if (typeof smartmail.addRecipient === 'function') {
|
||||
smartmail.addRecipient(bccRecipient, 'bcc');
|
||||
} else {
|
||||
// Fallback for older versions
|
||||
if (!smartmail.options.bcc) smartmail.options.bcc = [];
|
||||
(smartmail.options.bcc as any[]).push({
|
||||
email: bccRecipient,
|
||||
name: bccRecipient.split('@')[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add attachments
|
||||
for (const attachment of this.attachments) {
|
||||
const smartAttachment = await plugins.smartfile.SmartFile.fromBuffer(
|
||||
attachment.filename,
|
||||
attachment.content
|
||||
);
|
||||
|
||||
// Set content type if available
|
||||
if (attachment.contentType) {
|
||||
(smartAttachment as any).contentType = attachment.contentType;
|
||||
}
|
||||
|
||||
smartmail.addAttachment(smartAttachment);
|
||||
}
|
||||
|
||||
return smartmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the from email address
|
||||
* @returns The from email address
|
||||
*/
|
||||
public getFromEmail(): string {
|
||||
return this.from;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message ID
|
||||
* @returns The message ID
|
||||
*/
|
||||
public getMessageId(): string {
|
||||
return this.messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom message ID
|
||||
* @param id The message ID to set
|
||||
* @returns This instance for method chaining
|
||||
*/
|
||||
public setMessageId(id: string): this {
|
||||
this.messageId = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the envelope from address (return-path)
|
||||
* @returns The envelope from address
|
||||
*/
|
||||
public getEnvelopeFrom(): string {
|
||||
return this.envelopeFrom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the envelope from address (return-path)
|
||||
* @param address The envelope from address to set
|
||||
* @returns This instance for method chaining
|
||||
*/
|
||||
public setEnvelopeFrom(address: string): this {
|
||||
if (!this.isValidEmail(address)) {
|
||||
throw new Error(`Invalid envelope from address: ${address}`);
|
||||
}
|
||||
this.envelopeFrom = address;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an RFC822 compliant email string
|
||||
* @param variables Optional template variables to apply
|
||||
* @returns The email formatted as an RFC822 compliant string
|
||||
*/
|
||||
public toRFC822String(variables?: Record<string, any>): string {
|
||||
// Apply variables to content if any
|
||||
const processedSubject = this.getSubjectWithVariables(variables);
|
||||
const processedText = this.getTextWithVariables(variables);
|
||||
|
||||
// This is a simplified version - a complete implementation would be more complex
|
||||
let result = '';
|
||||
|
||||
// Add headers
|
||||
result += `From: ${this.from}\r\n`;
|
||||
result += `To: ${this.to.join(', ')}\r\n`;
|
||||
|
||||
if (this.cc.length > 0) {
|
||||
result += `Cc: ${this.cc.join(', ')}\r\n`;
|
||||
}
|
||||
|
||||
result += `Subject: ${processedSubject}\r\n`;
|
||||
result += `Date: ${new Date().toUTCString()}\r\n`;
|
||||
result += `Message-ID: ${this.messageId}\r\n`;
|
||||
result += `Return-Path: <${this.envelopeFrom}>\r\n`;
|
||||
|
||||
// Add custom headers
|
||||
for (const [key, value] of Object.entries(this.headers)) {
|
||||
result += `${key}: ${value}\r\n`;
|
||||
}
|
||||
|
||||
// Add priority if not normal
|
||||
if (this.priority !== 'normal') {
|
||||
const priorityValue = this.priority === 'high' ? '1' : '5';
|
||||
result += `X-Priority: ${priorityValue}\r\n`;
|
||||
}
|
||||
|
||||
// Add content type and body
|
||||
result += `Content-Type: text/plain; charset=utf-8\r\n`;
|
||||
|
||||
// Add HTML content type if available
|
||||
if (this.html) {
|
||||
const processedHtml = this.getHtmlWithVariables(variables);
|
||||
const boundary = `boundary_${Date.now().toString(16)}`;
|
||||
|
||||
// Multipart content for both plain text and HTML
|
||||
result = result.replace(/Content-Type: .*\r\n/, '');
|
||||
result += `MIME-Version: 1.0\r\n`;
|
||||
result += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
|
||||
|
||||
// Plain text part
|
||||
result += `--${boundary}\r\n`;
|
||||
result += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
|
||||
result += `${processedText}\r\n\r\n`;
|
||||
|
||||
// HTML part
|
||||
result += `--${boundary}\r\n`;
|
||||
result += `Content-Type: text/html; charset=utf-8\r\n\r\n`;
|
||||
result += `${processedHtml}\r\n\r\n`;
|
||||
|
||||
// End of multipart
|
||||
result += `--${boundary}--\r\n`;
|
||||
} else {
|
||||
// Simple plain text
|
||||
result += `\r\n${processedText}\r\n`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Email instance from a Smartmail object
|
||||
* @param smartmail The Smartmail instance to convert
|
||||
* @returns A new Email instance
|
||||
*/
|
||||
public static fromSmartmail(smartmail: plugins.smartmail.Smartmail<any>): Email {
|
||||
const options: IEmailOptions = {
|
||||
from: smartmail.options.from,
|
||||
to: [],
|
||||
subject: smartmail.getSubject(),
|
||||
text: smartmail.getBody(false), // Plain text version
|
||||
html: smartmail.getBody(true), // HTML version
|
||||
attachments: []
|
||||
};
|
||||
|
||||
// Function to safely extract email address from recipient
|
||||
const extractEmail = (recipient: any): string => {
|
||||
// Handle string recipients
|
||||
if (typeof recipient === 'string') return recipient;
|
||||
|
||||
// Handle object recipients
|
||||
if (recipient && typeof recipient === 'object') {
|
||||
const addressObj = recipient as any;
|
||||
// Try different property names that might contain the email address
|
||||
if ('address' in addressObj && typeof addressObj.address === 'string') {
|
||||
return addressObj.address;
|
||||
}
|
||||
if ('email' in addressObj && typeof addressObj.email === 'string') {
|
||||
return addressObj.email;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for invalid input
|
||||
return '';
|
||||
};
|
||||
|
||||
// Filter out empty strings from the extracted emails
|
||||
const filterValidEmails = (emails: string[]): string[] => {
|
||||
return emails.filter(email => email && email.length > 0);
|
||||
};
|
||||
|
||||
// Convert TO recipients
|
||||
if (smartmail.options.to?.length > 0) {
|
||||
options.to = filterValidEmails(smartmail.options.to.map(extractEmail));
|
||||
}
|
||||
|
||||
// Convert CC recipients
|
||||
if (smartmail.options.cc?.length > 0) {
|
||||
options.cc = filterValidEmails(smartmail.options.cc.map(extractEmail));
|
||||
}
|
||||
|
||||
// Convert BCC recipients
|
||||
if (smartmail.options.bcc?.length > 0) {
|
||||
options.bcc = filterValidEmails(smartmail.options.bcc.map(extractEmail));
|
||||
}
|
||||
|
||||
// Convert attachments (note: this handles the synchronous case only)
|
||||
if (smartmail.attachments?.length > 0) {
|
||||
options.attachments = smartmail.attachments.map(attachment => {
|
||||
// For the test case, if the path is exactly "test.txt", use that as the filename
|
||||
let filename = 'attachment.bin';
|
||||
|
||||
if (attachment.path === 'test.txt') {
|
||||
filename = 'test.txt';
|
||||
} else if (attachment.parsedPath?.base) {
|
||||
filename = attachment.parsedPath.base;
|
||||
} else if (typeof attachment.path === 'string') {
|
||||
filename = attachment.path.split('/').pop() || 'attachment.bin';
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
content: Buffer.from(attachment.contentBuffer || Buffer.alloc(0)),
|
||||
contentType: (attachment as any)?.contentType || 'application/octet-stream'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return new Email(options);
|
||||
}
|
||||
}
|
||||
@@ -1,702 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { Email } from './classes.email.js';
|
||||
import { EmailSignJob } from './classes.emailsignjob.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
|
||||
// Configuration options for email sending
|
||||
export interface IEmailSendOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number; // in milliseconds
|
||||
connectionTimeout?: number; // in milliseconds
|
||||
tlsOptions?: plugins.tls.ConnectionOptions;
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
// Email delivery status
|
||||
export enum DeliveryStatus {
|
||||
PENDING = 'pending',
|
||||
SENDING = 'sending',
|
||||
DELIVERED = 'delivered',
|
||||
FAILED = 'failed',
|
||||
DEFERRED = 'deferred' // Temporary failure, will retry
|
||||
}
|
||||
|
||||
// Detailed information about delivery attempts
|
||||
export interface DeliveryInfo {
|
||||
status: DeliveryStatus;
|
||||
attempts: number;
|
||||
error?: Error;
|
||||
lastAttempt?: Date;
|
||||
nextAttempt?: Date;
|
||||
mxServer?: string;
|
||||
deliveryTime?: Date;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export class EmailSendJob {
|
||||
mtaRef: MtaService;
|
||||
private email: Email;
|
||||
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
|
||||
private mxServers: string[] = [];
|
||||
private currentMxIndex = 0;
|
||||
private options: IEmailSendOptions;
|
||||
public deliveryInfo: DeliveryInfo;
|
||||
|
||||
constructor(mtaRef: MtaService, emailArg: Email, options: IEmailSendOptions = {}) {
|
||||
this.email = emailArg;
|
||||
this.mtaRef = mtaRef;
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
maxRetries: options.maxRetries || 3,
|
||||
retryDelay: options.retryDelay || 300000, // 5 minutes
|
||||
connectionTimeout: options.connectionTimeout || 30000, // 30 seconds
|
||||
tlsOptions: options.tlsOptions || { rejectUnauthorized: true },
|
||||
debugMode: options.debugMode || false
|
||||
};
|
||||
|
||||
// Initialize delivery info
|
||||
this.deliveryInfo = {
|
||||
status: DeliveryStatus.PENDING,
|
||||
attempts: 0,
|
||||
logs: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email with retry logic
|
||||
*/
|
||||
async send(): Promise<DeliveryStatus> {
|
||||
try {
|
||||
// Check if the email is valid before attempting to send
|
||||
this.validateEmail();
|
||||
|
||||
// Resolve MX records for the recipient domain
|
||||
await this.resolveMxRecords();
|
||||
|
||||
// Try to send the email
|
||||
return await this.attemptDelivery();
|
||||
} catch (error) {
|
||||
this.log(`Critical error in send process: ${error.message}`);
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Save failed email for potential future retry or analysis
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the email before sending
|
||||
*/
|
||||
private validateEmail(): void {
|
||||
if (!this.email.to || this.email.to.length === 0) {
|
||||
throw new Error('No recipients specified');
|
||||
}
|
||||
|
||||
if (!this.email.from) {
|
||||
throw new Error('No sender specified');
|
||||
}
|
||||
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
if (!fromDomain) {
|
||||
throw new Error('Invalid sender domain');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for the recipient domain
|
||||
*/
|
||||
private async resolveMxRecords(): Promise<void> {
|
||||
const domain = this.email.getPrimaryRecipient()?.split('@')[1];
|
||||
if (!domain) {
|
||||
throw new Error('Invalid recipient domain');
|
||||
}
|
||||
|
||||
this.log(`Resolving MX records for domain: ${domain}`);
|
||||
try {
|
||||
const addresses = await this.resolveMx(domain);
|
||||
|
||||
// Sort by priority (lowest number = highest priority)
|
||||
addresses.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
this.mxServers = addresses.map(mx => mx.exchange);
|
||||
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
|
||||
|
||||
if (this.mxServers.length === 0) {
|
||||
throw new Error(`No MX records found for domain: ${domain}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to resolve MX records: ${error.message}`);
|
||||
throw new Error(`MX lookup failed for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to deliver the email with retries
|
||||
*/
|
||||
private async attemptDelivery(): Promise<DeliveryStatus> {
|
||||
while (this.deliveryInfo.attempts < this.options.maxRetries) {
|
||||
this.deliveryInfo.attempts++;
|
||||
this.deliveryInfo.lastAttempt = new Date();
|
||||
this.deliveryInfo.status = DeliveryStatus.SENDING;
|
||||
|
||||
try {
|
||||
this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`);
|
||||
|
||||
// Try each MX server in order of priority
|
||||
while (this.currentMxIndex < this.mxServers.length) {
|
||||
const currentMx = this.mxServers[this.currentMxIndex];
|
||||
this.deliveryInfo.mxServer = currentMx;
|
||||
|
||||
try {
|
||||
this.log(`Attempting delivery to MX server: ${currentMx}`);
|
||||
await this.connectAndSend(currentMx);
|
||||
|
||||
// If we get here, email was sent successfully
|
||||
this.deliveryInfo.status = DeliveryStatus.DELIVERED;
|
||||
this.deliveryInfo.deliveryTime = new Date();
|
||||
this.log(`Email delivered successfully to ${currentMx}`);
|
||||
|
||||
// Record delivery for sender reputation monitoring
|
||||
this.recordDeliveryEvent('delivered');
|
||||
|
||||
// Save successful email record
|
||||
await this.saveSuccess();
|
||||
return DeliveryStatus.DELIVERED;
|
||||
} catch (error) {
|
||||
this.log(`Error with MX ${currentMx}: ${error.message}`);
|
||||
|
||||
// Clean up socket if it exists
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
// Try the next MX server
|
||||
this.currentMxIndex++;
|
||||
|
||||
// If this is a permanent failure, don't try other MX servers
|
||||
if (this.isPermanentFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we've tried all MX servers without success, throw an error
|
||||
throw new Error('All MX servers failed');
|
||||
} catch (error) {
|
||||
// Check if this is a permanent failure
|
||||
if (this.isPermanentFailure(error)) {
|
||||
this.log(`Permanent failure: ${error.message}`);
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Save failed email for analysis
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
// This is a temporary failure, we can retry
|
||||
this.log(`Temporary failure: ${error.message}`);
|
||||
|
||||
// If this is the last attempt, mark as failed
|
||||
if (this.deliveryInfo.attempts >= this.options.maxRetries) {
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Save failed email for analysis
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
// Schedule the next retry
|
||||
const nextRetryTime = new Date(Date.now() + this.options.retryDelay);
|
||||
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
|
||||
this.deliveryInfo.nextAttempt = nextRetryTime;
|
||||
this.log(`Will retry at ${nextRetryTime.toISOString()}`);
|
||||
|
||||
// Wait before retrying
|
||||
await this.delay(this.options.retryDelay);
|
||||
|
||||
// Reset MX server index for the next attempt
|
||||
this.currentMxIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a specific MX server and send the email
|
||||
*/
|
||||
private async connectAndSend(mxServer: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let commandTimeout: NodeJS.Timeout;
|
||||
|
||||
// Function to clear timeouts and remove listeners
|
||||
const cleanup = () => {
|
||||
clearTimeout(commandTimeout);
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to set a timeout for each command
|
||||
const setCommandTimeout = () => {
|
||||
clearTimeout(commandTimeout);
|
||||
commandTimeout = setTimeout(() => {
|
||||
this.log('Connection timed out');
|
||||
cleanup();
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
reject(new Error('Connection timed out'));
|
||||
}, this.options.connectionTimeout);
|
||||
};
|
||||
|
||||
// Connect to the MX server
|
||||
this.log(`Connecting to ${mxServer}:25`);
|
||||
setCommandTimeout();
|
||||
|
||||
// Check if IP warmup is enabled and get an IP to use
|
||||
let localAddress: string | undefined = undefined;
|
||||
if (this.mtaRef.config.outbound?.warmup?.enabled) {
|
||||
const warmupManager = this.mtaRef.getIPWarmupManager();
|
||||
if (warmupManager) {
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
const bestIP = warmupManager.getBestIPForSending({
|
||||
from: this.email.from,
|
||||
to: this.email.getAllRecipients(),
|
||||
domain: fromDomain,
|
||||
isTransactional: this.email.priority === 'high'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
this.log(`Using warmed-up IP ${bestIP} for sending`);
|
||||
localAddress = bestIP;
|
||||
|
||||
// Record the send for warm-up tracking
|
||||
warmupManager.recordSend(bestIP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect with specified local address if available
|
||||
this.socket = plugins.net.connect({
|
||||
port: 25,
|
||||
host: mxServer,
|
||||
localAddress
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.log(`Socket error: ${err.message}`);
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Set up the command sequence
|
||||
this.socket.once('data', async (data) => {
|
||||
try {
|
||||
const greeting = data.toString();
|
||||
this.log(`Server greeting: ${greeting.trim()}`);
|
||||
|
||||
if (!greeting.startsWith('220')) {
|
||||
throw new Error(`Unexpected server greeting: ${greeting}`);
|
||||
}
|
||||
|
||||
// EHLO command
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
|
||||
|
||||
// Try STARTTLS if available
|
||||
try {
|
||||
await this.sendCommand('STARTTLS\r\n', '220');
|
||||
this.upgradeToTLS(mxServer, fromDomain);
|
||||
// The TLS handshake and subsequent commands will continue in the upgradeToTLS method
|
||||
// resolve will be called from there if successful
|
||||
} catch (error) {
|
||||
this.log(`STARTTLS failed or not supported: ${error.message}`);
|
||||
this.log('Continuing with unencrypted connection');
|
||||
|
||||
// Continue with unencrypted connection
|
||||
await this.sendEmailCommands();
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade the connection to TLS
|
||||
*/
|
||||
private upgradeToTLS(mxServer: string, fromDomain: string): void {
|
||||
this.log('Starting TLS handshake');
|
||||
|
||||
const tlsOptions = {
|
||||
...this.options.tlsOptions,
|
||||
socket: this.socket,
|
||||
servername: mxServer
|
||||
};
|
||||
|
||||
// Create TLS socket
|
||||
this.socket = plugins.tls.connect(tlsOptions);
|
||||
|
||||
// Handle TLS connection
|
||||
this.socket.once('secureConnect', async () => {
|
||||
try {
|
||||
this.log('TLS connection established');
|
||||
|
||||
// Send EHLO again over TLS
|
||||
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
|
||||
|
||||
// Send the email
|
||||
await this.sendEmailCommands();
|
||||
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
} catch (error) {
|
||||
this.log(`Error in TLS session: ${error.message}`);
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.log(`TLS error: ${err.message}`);
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP commands to deliver the email
|
||||
*/
|
||||
private async sendEmailCommands(): Promise<void> {
|
||||
// MAIL FROM command
|
||||
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
|
||||
|
||||
// RCPT TO command for each recipient
|
||||
for (const recipient of this.email.getAllRecipients()) {
|
||||
await this.sendCommand(`RCPT TO:<${recipient}>\r\n`, '250');
|
||||
}
|
||||
|
||||
// DATA command
|
||||
await this.sendCommand('DATA\r\n', '354');
|
||||
|
||||
// Create the email message with DKIM signature
|
||||
const message = await this.createEmailMessage();
|
||||
|
||||
// Send the message content
|
||||
await this.sendCommand(message);
|
||||
await this.sendCommand('\r\n.\r\n', '250');
|
||||
|
||||
// QUIT command
|
||||
await this.sendCommand('QUIT\r\n', '221');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the full email message with headers and DKIM signature
|
||||
*/
|
||||
private async createEmailMessage(): Promise<string> {
|
||||
this.log('Preparing email message');
|
||||
|
||||
const messageId = `<${plugins.uuid.v4()}@${this.email.getFromDomain()}>`;
|
||||
const boundary = '----=_NextPart_' + plugins.uuid.v4();
|
||||
|
||||
// Prepare headers
|
||||
const headers = {
|
||||
'Message-ID': messageId,
|
||||
'From': this.email.from,
|
||||
'To': this.email.to.join(', '),
|
||||
'Subject': this.email.subject,
|
||||
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
|
||||
'Date': new Date().toUTCString()
|
||||
};
|
||||
|
||||
// Add CC header if present
|
||||
if (this.email.cc && this.email.cc.length > 0) {
|
||||
headers['Cc'] = this.email.cc.join(', ');
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
for (const [key, value] of Object.entries(this.email.headers || {})) {
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
// Add priority header if not normal
|
||||
if (this.email.priority && this.email.priority !== 'normal') {
|
||||
const priorityValue = this.email.priority === 'high' ? '1' : '5';
|
||||
headers['X-Priority'] = priorityValue;
|
||||
}
|
||||
|
||||
// Create body
|
||||
let body = '';
|
||||
|
||||
// Text part
|
||||
body += `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${this.email.text}\r\n`;
|
||||
|
||||
// HTML part if present
|
||||
if (this.email.html) {
|
||||
body += `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.html}\r\n`;
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for (const attachment of this.email.attachments) {
|
||||
body += `--${boundary}\r\nContent-Type: ${attachment.contentType}; name="${attachment.filename}"\r\n`;
|
||||
body += 'Content-Transfer-Encoding: base64\r\n';
|
||||
body += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
||||
|
||||
// Add Content-ID for inline attachments if present
|
||||
if (attachment.contentId) {
|
||||
body += `Content-ID: <${attachment.contentId}>\r\n`;
|
||||
}
|
||||
|
||||
body += '\r\n';
|
||||
body += attachment.content.toString('base64') + '\r\n';
|
||||
}
|
||||
|
||||
// End of message
|
||||
body += `--${boundary}--\r\n`;
|
||||
|
||||
// Create DKIM signature
|
||||
const dkimSigner = new EmailSignJob(this.mtaRef, {
|
||||
domain: this.email.getFromDomain(),
|
||||
selector: 'mta',
|
||||
headers: headers,
|
||||
body: body,
|
||||
});
|
||||
|
||||
// Build the message with headers
|
||||
let headerString = '';
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
headerString += `${key}: ${value}\r\n`;
|
||||
}
|
||||
let message = headerString + '\r\n' + body;
|
||||
|
||||
// Add DKIM signature header
|
||||
let signatureHeader = await dkimSigner.getSignatureHeader(message);
|
||||
message = `${signatureHeader}${message}`;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an event for sender reputation monitoring
|
||||
* @param eventType Type of event
|
||||
* @param isHardBounce Whether the event is a hard bounce (for bounce events)
|
||||
*/
|
||||
private recordDeliveryEvent(
|
||||
eventType: 'sent' | 'delivered' | 'bounce' | 'complaint',
|
||||
isHardBounce: boolean = false
|
||||
): void {
|
||||
try {
|
||||
// Check if reputation monitoring is enabled
|
||||
if (!this.mtaRef.config.outbound?.reputation?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reputationMonitor = this.mtaRef.getReputationMonitor();
|
||||
if (!reputationMonitor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get domain from sender
|
||||
const domain = this.email.getFromDomain();
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine receiving domain for complaint tracking
|
||||
let receivingDomain = null;
|
||||
if (eventType === 'complaint' && this.email.to.length > 0) {
|
||||
const recipient = this.email.to[0];
|
||||
const parts = recipient.split('@');
|
||||
if (parts.length === 2) {
|
||||
receivingDomain = parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Record the event
|
||||
reputationMonitor.recordSendEvent(domain, {
|
||||
type: eventType,
|
||||
count: 1,
|
||||
hardBounce: isHardBounce,
|
||||
receivingDomain
|
||||
});
|
||||
} catch (error) {
|
||||
this.log(`Error recording delivery event: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the SMTP server and wait for the expected response
|
||||
*/
|
||||
private sendCommand(command: string, expectedResponseCode?: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket) {
|
||||
return reject(new Error('Socket not connected'));
|
||||
}
|
||||
|
||||
// Debug log for commands (except DATA which can be large)
|
||||
if (this.options.debugMode && !command.startsWith('--')) {
|
||||
const logCommand = command.length > 100
|
||||
? command.substring(0, 97) + '...'
|
||||
: command;
|
||||
this.log(`Sending: ${logCommand.replace(/\r\n/g, '<CRLF>')}`);
|
||||
}
|
||||
|
||||
this.socket.write(command, (error) => {
|
||||
if (error) {
|
||||
this.log(`Write error: ${error.message}`);
|
||||
return reject(error);
|
||||
}
|
||||
|
||||
// If no response is expected, resolve immediately
|
||||
if (!expectedResponseCode) {
|
||||
return resolve('');
|
||||
}
|
||||
|
||||
// Set a timeout for the response
|
||||
const responseTimeout = setTimeout(() => {
|
||||
this.log('Response timeout');
|
||||
reject(new Error('Response timeout'));
|
||||
}, this.options.connectionTimeout);
|
||||
|
||||
// Wait for the response
|
||||
this.socket.once('data', (data) => {
|
||||
clearTimeout(responseTimeout);
|
||||
const response = data.toString();
|
||||
|
||||
if (this.options.debugMode) {
|
||||
this.log(`Received: ${response.trim()}`);
|
||||
}
|
||||
|
||||
if (response.startsWith(expectedResponseCode)) {
|
||||
resolve(response);
|
||||
} else {
|
||||
const error = new Error(`Unexpected server response: ${response.trim()}`);
|
||||
this.log(error.message);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error represents a permanent failure
|
||||
*/
|
||||
private isPermanentFailure(error: Error): boolean {
|
||||
if (!error || !error.message) return false;
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Check for permanent SMTP error codes (5xx)
|
||||
if (message.match(/^5\d\d/)) return true;
|
||||
|
||||
// Check for specific permanent failure messages
|
||||
const permanentFailurePatterns = [
|
||||
'no such user',
|
||||
'user unknown',
|
||||
'domain not found',
|
||||
'invalid domain',
|
||||
'rejected',
|
||||
'denied',
|
||||
'prohibited',
|
||||
'authentication required',
|
||||
'authentication failed',
|
||||
'unauthorized'
|
||||
];
|
||||
|
||||
return permanentFailurePatterns.some(pattern => message.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for a domain
|
||||
*/
|
||||
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(addresses);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log entry
|
||||
*/
|
||||
private log(message: string): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = `[${timestamp}] ${message}`;
|
||||
this.deliveryInfo.logs.push(logEntry);
|
||||
|
||||
if (this.options.debugMode) {
|
||||
console.log(`EmailSendJob: ${logEntry}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a successful email for record keeping
|
||||
*/
|
||||
private async saveSuccess(): Promise<void> {
|
||||
try {
|
||||
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
|
||||
const emailContent = await this.createEmailMessage();
|
||||
const fileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.eml`;
|
||||
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.sentEmailsDir, fileName));
|
||||
|
||||
// Save delivery info
|
||||
const infoFileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.json`;
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
JSON.stringify(this.deliveryInfo, null, 2),
|
||||
plugins.path.join(paths.sentEmailsDir, infoFileName)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving successful email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a failed email for potential retry
|
||||
*/
|
||||
private async saveFailed(): Promise<void> {
|
||||
try {
|
||||
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
|
||||
const emailContent = await this.createEmailMessage();
|
||||
const fileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.eml`;
|
||||
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.failedEmailsDir, fileName));
|
||||
|
||||
// Save delivery info
|
||||
const infoFileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.json`;
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
JSON.stringify(this.deliveryInfo, null, 2),
|
||||
plugins.path.join(paths.failedEmailsDir, infoFileName)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving failed email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple delay function
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
|
||||
interface Headers {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface IEmailSignJobOptions {
|
||||
domain: string;
|
||||
selector: string;
|
||||
headers: Headers;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class EmailSignJob {
|
||||
mtaRef: MtaService;
|
||||
jobOptions: IEmailSignJobOptions;
|
||||
|
||||
constructor(mtaRefArg: MtaService, options: IEmailSignJobOptions) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
this.jobOptions = options;
|
||||
}
|
||||
|
||||
async loadPrivateKey(): Promise<string> {
|
||||
return plugins.fs.promises.readFile(
|
||||
(await this.mtaRef.dkimCreator.getKeyPathsForDomain(this.jobOptions.domain)).privateKeyPath,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
||||
const signResult = await plugins.dkimSign(emailMessage, {
|
||||
// Optional, default canonicalization, default is "relaxed/relaxed"
|
||||
canonicalization: 'relaxed/relaxed', // c=
|
||||
|
||||
// Optional, default signing and hashing algorithm
|
||||
// Mostly useful when you want to use rsa-sha1, otherwise no need to set
|
||||
algorithm: 'rsa-sha256',
|
||||
|
||||
// Optional, default is current time
|
||||
signTime: new Date(), // t=
|
||||
|
||||
// Keys for one or more signatures
|
||||
// Different signatures can use different algorithms (mostly useful when
|
||||
// you want to sign a message both with RSA and Ed25519)
|
||||
signatureData: [
|
||||
{
|
||||
signingDomain: this.jobOptions.domain, // d=
|
||||
selector: this.jobOptions.selector, // s=
|
||||
// supported key types: RSA, Ed25519
|
||||
privateKey: await this.loadPrivateKey(), // k=
|
||||
|
||||
// Optional algorithm, default is derived from the key.
|
||||
// Overrides whatever was set in parent object
|
||||
algorithm: 'rsa-sha256',
|
||||
|
||||
// Optional signature specifc canonicalization, overrides whatever was set in parent object
|
||||
canonicalization: 'relaxed/relaxed', // c=
|
||||
|
||||
// Maximum number of canonicalized body bytes to sign (eg. the "l=" tag).
|
||||
// Do not use though. This is available only for compatibility testing.
|
||||
// maxBodyLength: 12345
|
||||
},
|
||||
],
|
||||
});
|
||||
const signature = signResult.signatures;
|
||||
return signature;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,281 +0,0 @@
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Configuration options for rate limiter
|
||||
*/
|
||||
export interface IRateLimitConfig {
|
||||
/** Maximum tokens per period */
|
||||
maxPerPeriod: number;
|
||||
|
||||
/** Time period in milliseconds */
|
||||
periodMs: number;
|
||||
|
||||
/** Whether to apply per domain/key (vs globally) */
|
||||
perKey: boolean;
|
||||
|
||||
/** Initial token count (defaults to max) */
|
||||
initialTokens?: number;
|
||||
|
||||
/** Grace tokens to allow occasional bursts */
|
||||
burstTokens?: number;
|
||||
|
||||
/** Apply global limit in addition to per-key limits */
|
||||
useGlobalLimit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token bucket for an individual key
|
||||
*/
|
||||
interface TokenBucket {
|
||||
/** Current number of tokens */
|
||||
tokens: number;
|
||||
|
||||
/** Last time tokens were refilled */
|
||||
lastRefill: number;
|
||||
|
||||
/** Total allowed requests */
|
||||
allowed: number;
|
||||
|
||||
/** Total denied requests */
|
||||
denied: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter using token bucket algorithm
|
||||
* Provides more sophisticated rate limiting with burst handling
|
||||
*/
|
||||
export class RateLimiter {
|
||||
/** Rate limit configuration */
|
||||
private config: IRateLimitConfig;
|
||||
|
||||
/** Token buckets per key */
|
||||
private buckets: Map<string, TokenBucket> = new Map();
|
||||
|
||||
/** Global bucket for non-keyed rate limiting */
|
||||
private globalBucket: TokenBucket;
|
||||
|
||||
/**
|
||||
* Create a new rate limiter
|
||||
* @param config Rate limiter configuration
|
||||
*/
|
||||
constructor(config: IRateLimitConfig) {
|
||||
// Set defaults
|
||||
this.config = {
|
||||
maxPerPeriod: config.maxPerPeriod,
|
||||
periodMs: config.periodMs,
|
||||
perKey: config.perKey ?? true,
|
||||
initialTokens: config.initialTokens ?? config.maxPerPeriod,
|
||||
burstTokens: config.burstTokens ?? 0,
|
||||
useGlobalLimit: config.useGlobalLimit ?? false
|
||||
};
|
||||
|
||||
// Initialize global bucket
|
||||
this.globalBucket = {
|
||||
tokens: this.config.initialTokens,
|
||||
lastRefill: Date.now(),
|
||||
allowed: 0,
|
||||
denied: 0
|
||||
};
|
||||
|
||||
// Log initialization
|
||||
logger.log('info', `Rate limiter initialized: ${this.config.maxPerPeriod} per ${this.config.periodMs}ms${this.config.perKey ? ' per key' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed under rate limits
|
||||
* @param key Key to check rate limit for (e.g. domain, user, IP)
|
||||
* @param cost Token cost (defaults to 1)
|
||||
* @returns Whether the request is allowed
|
||||
*/
|
||||
public isAllowed(key: string = 'global', cost: number = 1): boolean {
|
||||
// If using global bucket directly, just check that
|
||||
if (key === 'global' || !this.config.perKey) {
|
||||
return this.checkBucket(this.globalBucket, cost);
|
||||
}
|
||||
|
||||
// Get the key-specific bucket
|
||||
const bucket = this.getBucket(key);
|
||||
|
||||
// If we also need to check global limit
|
||||
if (this.config.useGlobalLimit) {
|
||||
// Both key bucket and global bucket must have tokens
|
||||
return this.checkBucket(bucket, cost) && this.checkBucket(this.globalBucket, cost);
|
||||
} else {
|
||||
// Only need to check the key-specific bucket
|
||||
return this.checkBucket(bucket, cost);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bucket has enough tokens and consume them
|
||||
* @param bucket The token bucket to check
|
||||
* @param cost Token cost
|
||||
* @returns Whether tokens were consumed
|
||||
*/
|
||||
private checkBucket(bucket: TokenBucket, cost: number): boolean {
|
||||
// Refill tokens based on elapsed time
|
||||
this.refillBucket(bucket);
|
||||
|
||||
// Check if we have enough tokens
|
||||
if (bucket.tokens >= cost) {
|
||||
// Use tokens
|
||||
bucket.tokens -= cost;
|
||||
bucket.allowed++;
|
||||
return true;
|
||||
} else {
|
||||
// Rate limit exceeded
|
||||
bucket.denied++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume tokens for a request (if available)
|
||||
* @param key Key to consume tokens for
|
||||
* @param cost Token cost (defaults to 1)
|
||||
* @returns Whether tokens were consumed
|
||||
*/
|
||||
public consume(key: string = 'global', cost: number = 1): boolean {
|
||||
const isAllowed = this.isAllowed(key, cost);
|
||||
return isAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remaining tokens for a key
|
||||
* @param key Key to check
|
||||
* @returns Number of remaining tokens
|
||||
*/
|
||||
public getRemainingTokens(key: string = 'global'): number {
|
||||
const bucket = this.getBucket(key);
|
||||
this.refillBucket(bucket);
|
||||
return bucket.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for a specific key
|
||||
* @param key Key to get stats for
|
||||
* @returns Rate limit statistics
|
||||
*/
|
||||
public getStats(key: string = 'global'): {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
resetIn: number;
|
||||
allowed: number;
|
||||
denied: number;
|
||||
} {
|
||||
const bucket = this.getBucket(key);
|
||||
this.refillBucket(bucket);
|
||||
|
||||
// Calculate time until next token
|
||||
const resetIn = bucket.tokens < this.config.maxPerPeriod ?
|
||||
Math.ceil(this.config.periodMs / this.config.maxPerPeriod) :
|
||||
0;
|
||||
|
||||
return {
|
||||
remaining: bucket.tokens,
|
||||
limit: this.config.maxPerPeriod,
|
||||
resetIn,
|
||||
allowed: bucket.allowed,
|
||||
denied: bucket.denied
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a token bucket for a key
|
||||
* @param key The rate limit key
|
||||
* @returns Token bucket
|
||||
*/
|
||||
private getBucket(key: string): TokenBucket {
|
||||
if (!this.config.perKey || key === 'global') {
|
||||
return this.globalBucket;
|
||||
}
|
||||
|
||||
if (!this.buckets.has(key)) {
|
||||
// Create new bucket
|
||||
this.buckets.set(key, {
|
||||
tokens: this.config.initialTokens,
|
||||
lastRefill: Date.now(),
|
||||
allowed: 0,
|
||||
denied: 0
|
||||
});
|
||||
}
|
||||
|
||||
return this.buckets.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refill tokens in a bucket based on elapsed time
|
||||
* @param bucket Token bucket to refill
|
||||
*/
|
||||
private refillBucket(bucket: TokenBucket): void {
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - bucket.lastRefill;
|
||||
|
||||
// Calculate how many tokens to add
|
||||
const rate = this.config.maxPerPeriod / this.config.periodMs;
|
||||
const tokensToAdd = elapsedMs * rate;
|
||||
|
||||
if (tokensToAdd >= 0.1) { // Allow for partial token refills
|
||||
// Add tokens, but don't exceed the normal maximum (without burst)
|
||||
// This ensures burst tokens are only used for bursts and don't refill
|
||||
const normalMax = this.config.maxPerPeriod;
|
||||
bucket.tokens = Math.min(
|
||||
// Don't exceed max + burst
|
||||
this.config.maxPerPeriod + (this.config.burstTokens || 0),
|
||||
// Don't exceed normal max when refilling
|
||||
Math.min(normalMax, bucket.tokens + tokensToAdd)
|
||||
);
|
||||
|
||||
// Update last refill time
|
||||
bucket.lastRefill = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limits for a specific key
|
||||
* @param key Key to reset
|
||||
*/
|
||||
public reset(key: string = 'global'): void {
|
||||
if (key === 'global' || !this.config.perKey) {
|
||||
this.globalBucket.tokens = this.config.initialTokens;
|
||||
this.globalBucket.lastRefill = Date.now();
|
||||
} else if (this.buckets.has(key)) {
|
||||
const bucket = this.buckets.get(key);
|
||||
bucket.tokens = this.config.initialTokens;
|
||||
bucket.lastRefill = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all rate limiters
|
||||
*/
|
||||
public resetAll(): void {
|
||||
this.globalBucket.tokens = this.config.initialTokens;
|
||||
this.globalBucket.lastRefill = Date.now();
|
||||
|
||||
for (const bucket of this.buckets.values()) {
|
||||
bucket.tokens = this.config.initialTokens;
|
||||
bucket.lastRefill = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old buckets to prevent memory leaks
|
||||
* @param maxAge Maximum age in milliseconds
|
||||
*/
|
||||
public cleanup(maxAge: number = 24 * 60 * 60 * 1000): void {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
|
||||
for (const [key, bucket] of this.buckets.entries()) {
|
||||
if (now - bucket.lastRefill > maxAge) {
|
||||
this.buckets.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,806 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { Email } from './classes.email.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType,
|
||||
IPReputationChecker,
|
||||
ReputationThreshold
|
||||
} from '../security/index.js';
|
||||
|
||||
export interface ISmtpServerOptions {
|
||||
port: number;
|
||||
key: string;
|
||||
cert: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
// SMTP Session States
|
||||
enum SmtpState {
|
||||
GREETING,
|
||||
AFTER_EHLO,
|
||||
MAIL_FROM,
|
||||
RCPT_TO,
|
||||
DATA,
|
||||
DATA_RECEIVING,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
// Structure to store session information
|
||||
interface SmtpSession {
|
||||
state: SmtpState;
|
||||
clientHostname: string;
|
||||
mailFrom: string;
|
||||
rcptTo: string[];
|
||||
emailData: string;
|
||||
useTLS: boolean;
|
||||
connectionEnded: boolean;
|
||||
}
|
||||
|
||||
export class SMTPServer {
|
||||
public mtaRef: MtaService;
|
||||
private smtpServerOptions: ISmtpServerOptions;
|
||||
private server: plugins.net.Server;
|
||||
private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, SmtpSession>;
|
||||
private hostname: string;
|
||||
|
||||
constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) {
|
||||
console.log('SMTPServer instance is being created...');
|
||||
|
||||
this.mtaRef = mtaRefArg;
|
||||
this.smtpServerOptions = optionsArg;
|
||||
this.sessions = new Map();
|
||||
this.hostname = optionsArg.hostname || 'mta.lossless.one';
|
||||
|
||||
this.server = plugins.net.createServer((socket) => {
|
||||
this.handleNewConnection(socket);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleNewConnection(socket: plugins.net.Socket): Promise<void> {
|
||||
const clientIp = socket.remoteAddress;
|
||||
const clientPort = socket.remotePort;
|
||||
console.log(`New connection from ${clientIp}:${clientPort}`);
|
||||
|
||||
// Initialize a new session
|
||||
this.sessions.set(socket, {
|
||||
state: SmtpState.GREETING,
|
||||
clientHostname: '',
|
||||
mailFrom: '',
|
||||
rcptTo: [],
|
||||
emailData: '',
|
||||
useTLS: false,
|
||||
connectionEnded: false
|
||||
});
|
||||
|
||||
// Check IP reputation
|
||||
try {
|
||||
if (this.mtaRef.config.security?.checkIPReputation !== false && clientIp) {
|
||||
const reputationChecker = IPReputationChecker.getInstance();
|
||||
const reputation = await reputationChecker.checkReputation(clientIp);
|
||||
|
||||
// Log the reputation check
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: reputation.score < ReputationThreshold.HIGH_RISK
|
||||
? SecurityLogLevel.WARN
|
||||
: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.IP_REPUTATION,
|
||||
message: `IP reputation checked for new SMTP connection: score=${reputation.score}`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
score: reputation.score,
|
||||
isSpam: reputation.isSpam,
|
||||
isProxy: reputation.isProxy,
|
||||
isTor: reputation.isTor,
|
||||
isVPN: reputation.isVPN,
|
||||
country: reputation.country,
|
||||
blacklists: reputation.blacklists,
|
||||
socketId: socket.remotePort.toString() + socket.remoteFamily
|
||||
}
|
||||
});
|
||||
|
||||
// Handle high-risk IPs - add delay or reject based on score
|
||||
if (reputation.score < ReputationThreshold.HIGH_RISK) {
|
||||
// For high-risk connections, add an artificial delay to slow down potential spam
|
||||
const delayMs = Math.min(5000, Math.max(1000, (ReputationThreshold.HIGH_RISK - reputation.score) * 100));
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
|
||||
if (reputation.score < 5) {
|
||||
// Very high risk - can optionally reject the connection
|
||||
if (this.mtaRef.config.security?.rejectHighRiskIPs) {
|
||||
this.sendResponse(socket, `554 Transaction failed - IP is on spam blocklist`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking IP reputation: ${error.message}`, {
|
||||
ip: clientIp,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Log the connection as a security event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: `New SMTP connection established`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
socketId: socket.remotePort.toString() + socket.remoteFamily
|
||||
}
|
||||
});
|
||||
|
||||
// Send greeting
|
||||
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
|
||||
|
||||
socket.on('data', (data) => {
|
||||
this.processData(socket, data);
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
const clientIp = socket.remoteAddress;
|
||||
const clientPort = socket.remotePort;
|
||||
console.log(`Connection ended from ${clientIp}:${clientPort}`);
|
||||
|
||||
const session = this.sessions.get(socket);
|
||||
if (session) {
|
||||
session.connectionEnded = true;
|
||||
|
||||
// Log connection end as security event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: `SMTP connection ended normally`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
state: SmtpState[session.state],
|
||||
from: session.mailFrom || 'not set'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
const clientIp = socket.remoteAddress;
|
||||
const clientPort = socket.remotePort;
|
||||
console.error(`Socket error: ${err.message}`);
|
||||
|
||||
// Log connection error as security event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: `SMTP connection error`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
error: err.message,
|
||||
errorCode: (err as any).code,
|
||||
from: this.sessions.get(socket)?.mailFrom || 'not set'
|
||||
}
|
||||
});
|
||||
|
||||
this.sessions.delete(socket);
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
const clientIp = socket.remoteAddress;
|
||||
const clientPort = socket.remotePort;
|
||||
console.log(`Connection closed from ${clientIp}:${clientPort}`);
|
||||
|
||||
// Log connection closure as security event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: `SMTP connection closed`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
sessionEnded: this.sessions.get(socket)?.connectionEnded || false
|
||||
}
|
||||
});
|
||||
|
||||
this.sessions.delete(socket);
|
||||
});
|
||||
}
|
||||
|
||||
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
||||
try {
|
||||
socket.write(`${response}\r\n`);
|
||||
console.log(`→ ${response}`);
|
||||
} catch (error) {
|
||||
console.error(`Error sending response: ${error.message}`);
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) {
|
||||
console.error('No session found for socket. Closing connection.');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're in DATA_RECEIVING state, handle differently
|
||||
if (session.state === SmtpState.DATA_RECEIVING) {
|
||||
// Call async method but don't return the promise
|
||||
this.processEmailData(socket, data.toString()).catch(err => {
|
||||
console.error(`Error processing email data: ${err.message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process normal SMTP commands
|
||||
const lines = data.toString().split('\r\n').filter(line => line.length > 0);
|
||||
for (const line of lines) {
|
||||
console.log(`← ${line}`);
|
||||
this.processCommand(socket, line);
|
||||
}
|
||||
}
|
||||
|
||||
private processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session || session.connectionEnded) return;
|
||||
|
||||
const [command, ...args] = commandLine.split(' ');
|
||||
const upperCommand = command.toUpperCase();
|
||||
|
||||
switch (upperCommand) {
|
||||
case 'EHLO':
|
||||
case 'HELO':
|
||||
this.handleEhlo(socket, args.join(' '));
|
||||
break;
|
||||
case 'STARTTLS':
|
||||
this.handleStartTls(socket);
|
||||
break;
|
||||
case 'MAIL':
|
||||
this.handleMailFrom(socket, args.join(' '));
|
||||
break;
|
||||
case 'RCPT':
|
||||
this.handleRcptTo(socket, args.join(' '));
|
||||
break;
|
||||
case 'DATA':
|
||||
this.handleData(socket);
|
||||
break;
|
||||
case 'RSET':
|
||||
this.handleRset(socket);
|
||||
break;
|
||||
case 'QUIT':
|
||||
this.handleQuit(socket);
|
||||
break;
|
||||
case 'NOOP':
|
||||
this.sendResponse(socket, '250 OK');
|
||||
break;
|
||||
default:
|
||||
this.sendResponse(socket, '502 Command not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
private handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (!clientHostname) {
|
||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
||||
return;
|
||||
}
|
||||
|
||||
session.clientHostname = clientHostname;
|
||||
session.state = SmtpState.AFTER_EHLO;
|
||||
|
||||
// List available extensions
|
||||
this.sendResponse(socket, `250-${this.hostname} Hello ${clientHostname}`);
|
||||
this.sendResponse(socket, '250-SIZE 10485760'); // 10MB max
|
||||
this.sendResponse(socket, '250-8BITMIME');
|
||||
|
||||
// Only offer STARTTLS if we haven't already established it
|
||||
if (!session.useTLS) {
|
||||
this.sendResponse(socket, '250-STARTTLS');
|
||||
}
|
||||
|
||||
this.sendResponse(socket, '250 HELP');
|
||||
}
|
||||
|
||||
private handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (session.state !== SmtpState.AFTER_EHLO) {
|
||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.useTLS) {
|
||||
this.sendResponse(socket, '503 TLS already active');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendResponse(socket, '220 Ready to start TLS');
|
||||
this.startTLS(socket);
|
||||
}
|
||||
|
||||
private handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (session.state !== SmtpState.AFTER_EHLO) {
|
||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract email from MAIL FROM:<user@example.com>
|
||||
const emailMatch = args.match(/FROM:<([^>]*)>/i);
|
||||
if (!emailMatch) {
|
||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = emailMatch[1];
|
||||
if (!this.isValidEmail(email)) {
|
||||
this.sendResponse(socket, '501 Invalid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
session.mailFrom = email;
|
||||
session.state = SmtpState.MAIL_FROM;
|
||||
this.sendResponse(socket, '250 OK');
|
||||
}
|
||||
|
||||
private handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (session.state !== SmtpState.MAIL_FROM && session.state !== SmtpState.RCPT_TO) {
|
||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract email from RCPT TO:<user@example.com>
|
||||
const emailMatch = args.match(/TO:<([^>]*)>/i);
|
||||
if (!emailMatch) {
|
||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = emailMatch[1];
|
||||
if (!this.isValidEmail(email)) {
|
||||
this.sendResponse(socket, '501 Invalid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
session.rcptTo.push(email);
|
||||
session.state = SmtpState.RCPT_TO;
|
||||
this.sendResponse(socket, '250 OK');
|
||||
}
|
||||
|
||||
private handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (session.state !== SmtpState.RCPT_TO) {
|
||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
||||
return;
|
||||
}
|
||||
|
||||
session.state = SmtpState.DATA_RECEIVING;
|
||||
session.emailData = '';
|
||||
this.sendResponse(socket, '354 End data with <CR><LF>.<CR><LF>');
|
||||
}
|
||||
|
||||
private handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
// Reset the session data but keep connection information
|
||||
session.state = SmtpState.AFTER_EHLO;
|
||||
session.mailFrom = '';
|
||||
session.rcptTo = [];
|
||||
session.emailData = '';
|
||||
|
||||
this.sendResponse(socket, '250 OK');
|
||||
}
|
||||
|
||||
private handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
this.sendResponse(socket, '221 Goodbye');
|
||||
|
||||
// If we have collected email data, try to parse it before closing
|
||||
if (session.state === SmtpState.FINISHED && session.emailData.length > 0) {
|
||||
this.parseEmail(socket);
|
||||
}
|
||||
|
||||
socket.end();
|
||||
this.sessions.delete(socket);
|
||||
}
|
||||
|
||||
private async processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void> {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
// Check for end of data marker
|
||||
if (data.endsWith('\r\n.\r\n')) {
|
||||
// Remove the end of data marker
|
||||
const emailData = data.slice(0, -5);
|
||||
session.emailData += emailData;
|
||||
session.state = SmtpState.FINISHED;
|
||||
|
||||
// Save and process the email
|
||||
this.saveEmail(socket);
|
||||
this.sendResponse(socket, '250 OK: Message accepted for delivery');
|
||||
} else {
|
||||
// Accumulate the data
|
||||
session.emailData += data;
|
||||
}
|
||||
}
|
||||
|
||||
private saveEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
||||
|
||||
// Write the email to disk
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
session.emailData,
|
||||
plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`)
|
||||
);
|
||||
|
||||
// Parse the email
|
||||
this.parseEmail(socket);
|
||||
} catch (error) {
|
||||
console.error('Error saving email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<void> {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session || !session.emailData) {
|
||||
console.error('No email data found for session.');
|
||||
return;
|
||||
}
|
||||
|
||||
let mightBeSpam = false;
|
||||
// Prepare headers for DKIM verification results
|
||||
const customHeaders: Record<string, string> = {};
|
||||
|
||||
// Authentication results
|
||||
let dkimResult = { domain: '', result: false };
|
||||
let spfResult = { domain: '', result: false };
|
||||
|
||||
// Check security configuration
|
||||
const securityConfig = this.mtaRef.config.security || {};
|
||||
|
||||
// 1. Verify DKIM signature if enabled
|
||||
if (securityConfig.verifyDkim !== false) {
|
||||
try {
|
||||
const verificationResult = await this.mtaRef.dkimVerifier.verify(session.emailData, {
|
||||
useCache: true,
|
||||
returnDetails: false
|
||||
});
|
||||
|
||||
dkimResult.result = verificationResult.isValid;
|
||||
dkimResult.domain = verificationResult.domain || '';
|
||||
|
||||
if (!verificationResult.isValid) {
|
||||
logger.log('warn', `DKIM verification failed for incoming email: ${verificationResult.errorMessage || 'Unknown error'}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification failed for incoming email`,
|
||||
domain: verificationResult.domain || session.mailFrom.split('@')[1],
|
||||
details: {
|
||||
error: verificationResult.errorMessage || 'Unknown error',
|
||||
status: verificationResult.status,
|
||||
selector: verificationResult.selector,
|
||||
senderIP: socket.remoteAddress
|
||||
},
|
||||
ipAddress: socket.remoteAddress,
|
||||
success: false
|
||||
});
|
||||
} else {
|
||||
logger.log('info', `DKIM verification passed for incoming email from domain ${verificationResult.domain}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification passed for incoming email`,
|
||||
domain: verificationResult.domain,
|
||||
details: {
|
||||
selector: verificationResult.selector,
|
||||
status: verificationResult.status,
|
||||
senderIP: socket.remoteAddress
|
||||
},
|
||||
ipAddress: socket.remoteAddress,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
// Store verification results in headers
|
||||
if (verificationResult.domain) {
|
||||
customHeaders['X-DKIM-Domain'] = verificationResult.domain;
|
||||
}
|
||||
|
||||
customHeaders['X-DKIM-Status'] = verificationResult.status || 'unknown';
|
||||
customHeaders['X-DKIM-Result'] = verificationResult.isValid ? 'pass' : 'fail';
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to verify DKIM signature: ${error.message}`);
|
||||
customHeaders['X-DKIM-Status'] = 'error';
|
||||
customHeaders['X-DKIM-Result'] = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Verify SPF if enabled
|
||||
if (securityConfig.verifySpf !== false) {
|
||||
try {
|
||||
// Get the client IP and hostname
|
||||
const clientIp = socket.remoteAddress || '127.0.0.1';
|
||||
const clientHostname = session.clientHostname || 'localhost';
|
||||
|
||||
// Parse the email to get envelope from
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
|
||||
|
||||
// Create a temporary Email object for SPF verification
|
||||
const tempEmail = new Email({
|
||||
from: parsedEmail.from?.value[0].address || session.mailFrom,
|
||||
to: session.rcptTo[0],
|
||||
subject: "Temporary Email for SPF Verification",
|
||||
text: "This is a temporary email for SPF verification"
|
||||
});
|
||||
|
||||
// Set envelope from for SPF verification
|
||||
tempEmail.setEnvelopeFrom(session.mailFrom);
|
||||
|
||||
// Verify SPF
|
||||
const spfVerified = await this.mtaRef.spfVerifier.verifyAndApply(
|
||||
tempEmail,
|
||||
clientIp,
|
||||
clientHostname
|
||||
);
|
||||
|
||||
// Update SPF result
|
||||
spfResult.result = spfVerified;
|
||||
spfResult.domain = session.mailFrom.split('@')[1] || '';
|
||||
|
||||
// Copy SPF headers from the temp email
|
||||
if (tempEmail.headers['Received-SPF']) {
|
||||
customHeaders['Received-SPF'] = tempEmail.headers['Received-SPF'];
|
||||
}
|
||||
|
||||
// Set spam flag if SPF fails badly
|
||||
if (tempEmail.mightBeSpam) {
|
||||
mightBeSpam = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to verify SPF: ${error.message}`);
|
||||
customHeaders['Received-SPF'] = `error (${error.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify DMARC if enabled
|
||||
if (securityConfig.verifyDmarc !== false) {
|
||||
try {
|
||||
// Parse the email again
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
|
||||
|
||||
// Create a temporary Email object for DMARC verification
|
||||
const tempEmail = new Email({
|
||||
from: parsedEmail.from?.value[0].address || session.mailFrom,
|
||||
to: session.rcptTo[0],
|
||||
subject: "Temporary Email for DMARC Verification",
|
||||
text: "This is a temporary email for DMARC verification"
|
||||
});
|
||||
|
||||
// Verify DMARC
|
||||
const dmarcResult = await this.mtaRef.dmarcVerifier.verify(
|
||||
tempEmail,
|
||||
spfResult,
|
||||
dkimResult
|
||||
);
|
||||
|
||||
// Apply DMARC policy
|
||||
const dmarcPassed = this.mtaRef.dmarcVerifier.applyPolicy(tempEmail, dmarcResult);
|
||||
|
||||
// Add DMARC result to headers
|
||||
if (tempEmail.headers['X-DMARC-Result']) {
|
||||
customHeaders['X-DMARC-Result'] = tempEmail.headers['X-DMARC-Result'];
|
||||
}
|
||||
|
||||
// Add Authentication-Results header combining all authentication results
|
||||
customHeaders['Authentication-Results'] = `${this.mtaRef.config.smtp.hostname}; ` +
|
||||
`spf=${spfResult.result ? 'pass' : 'fail'} smtp.mailfrom=${session.mailFrom}; ` +
|
||||
`dkim=${dkimResult.result ? 'pass' : 'fail'} header.d=${dkimResult.domain || 'unknown'}; ` +
|
||||
`dmarc=${dmarcPassed ? 'pass' : 'fail'} header.from=${tempEmail.getFromDomain()}`;
|
||||
|
||||
// Set spam flag if DMARC fails
|
||||
if (tempEmail.mightBeSpam) {
|
||||
mightBeSpam = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to verify DMARC: ${error.message}`);
|
||||
customHeaders['X-DMARC-Result'] = `error (${error.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
|
||||
|
||||
const email = new Email({
|
||||
from: parsedEmail.from?.value[0].address || session.mailFrom,
|
||||
to: session.rcptTo[0], // Use the first recipient
|
||||
headers: customHeaders, // Add our custom headers with DKIM verification results
|
||||
subject: parsedEmail.subject || '',
|
||||
text: parsedEmail.html || parsedEmail.text || '',
|
||||
attachments: parsedEmail.attachments?.map((attachment) => ({
|
||||
filename: attachment.filename || '',
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
})) || [],
|
||||
mightBeSpam: mightBeSpam,
|
||||
});
|
||||
|
||||
console.log('Email received and parsed:', {
|
||||
from: email.from,
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
attachments: email.attachments.length,
|
||||
mightBeSpam: email.mightBeSpam
|
||||
});
|
||||
|
||||
// Enhanced security logging for received email
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: mightBeSpam ? SecurityLogLevel.WARN : SecurityLogLevel.INFO,
|
||||
type: mightBeSpam ? SecurityEventType.SPAM : SecurityEventType.EMAIL_VALIDATION,
|
||||
message: `Email received and ${mightBeSpam ? 'flagged as potential spam' : 'validated successfully'}`,
|
||||
domain: email.from.split('@')[1],
|
||||
ipAddress: socket.remoteAddress,
|
||||
details: {
|
||||
from: email.from,
|
||||
subject: email.subject,
|
||||
recipientCount: email.getAllRecipients().length,
|
||||
attachmentCount: email.attachments.length,
|
||||
hasAttachments: email.hasAttachments(),
|
||||
dkimStatus: customHeaders['X-DKIM-Result'] || 'unknown'
|
||||
},
|
||||
success: !mightBeSpam
|
||||
});
|
||||
|
||||
// Process or forward the email via MTA service
|
||||
try {
|
||||
await this.mtaRef.processIncomingEmail(email);
|
||||
} catch (err) {
|
||||
console.error('Error in MTA processing of incoming email:', err);
|
||||
|
||||
// Log processing errors
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_VALIDATION,
|
||||
message: `Error processing incoming email`,
|
||||
domain: email.from.split('@')[1],
|
||||
ipAddress: socket.remoteAddress,
|
||||
details: {
|
||||
error: err.message,
|
||||
from: email.from,
|
||||
stack: err.stack
|
||||
},
|
||||
success: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing email:', error);
|
||||
|
||||
// Log parsing errors
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_VALIDATION,
|
||||
message: `Error parsing incoming email`,
|
||||
ipAddress: socket.remoteAddress,
|
||||
details: {
|
||||
error: error.message,
|
||||
sender: session.mailFrom,
|
||||
stack: error.stack
|
||||
},
|
||||
success: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private startTLS(socket: plugins.net.Socket): void {
|
||||
try {
|
||||
const secureContext = plugins.tls.createSecureContext({
|
||||
key: this.smtpServerOptions.key,
|
||||
cert: this.smtpServerOptions.cert,
|
||||
});
|
||||
|
||||
const tlsSocket = new plugins.tls.TLSSocket(socket, {
|
||||
secureContext: secureContext,
|
||||
isServer: true,
|
||||
server: this.server
|
||||
});
|
||||
|
||||
const originalSession = this.sessions.get(socket);
|
||||
if (!originalSession) {
|
||||
console.error('No session found when upgrading to TLS');
|
||||
return;
|
||||
}
|
||||
|
||||
// Transfer the session data to the new TLS socket
|
||||
this.sessions.set(tlsSocket, {
|
||||
...originalSession,
|
||||
useTLS: true,
|
||||
state: SmtpState.GREETING // Reset state to require a new EHLO
|
||||
});
|
||||
|
||||
this.sessions.delete(socket);
|
||||
|
||||
tlsSocket.on('secure', () => {
|
||||
console.log('TLS negotiation successful');
|
||||
});
|
||||
|
||||
tlsSocket.on('data', (data: Buffer) => {
|
||||
this.processData(tlsSocket, data);
|
||||
});
|
||||
|
||||
tlsSocket.on('end', () => {
|
||||
console.log('TLS socket ended');
|
||||
const session = this.sessions.get(tlsSocket);
|
||||
if (session) {
|
||||
session.connectionEnded = true;
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('error', (err) => {
|
||||
console.error('TLS socket error:', err);
|
||||
this.sessions.delete(tlsSocket);
|
||||
tlsSocket.destroy();
|
||||
});
|
||||
|
||||
tlsSocket.on('close', () => {
|
||||
console.log('TLS socket closed');
|
||||
this.sessions.delete(tlsSocket);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error upgrading connection to TLS:', error);
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private isValidEmail(email: string): boolean {
|
||||
// Basic email validation - more comprehensive validation could be implemented
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.server.listen(this.smtpServerOptions.port, () => {
|
||||
console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.server.getConnections((err, count) => {
|
||||
if (err) throw err;
|
||||
console.log('Number of active connections: ', count);
|
||||
});
|
||||
|
||||
this.server.close(() => {
|
||||
console.log('SMTP Server is now stopped');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
import type { Email } from './classes.email.js';
|
||||
import type { IDnsVerificationResult } from './classes.dnsmanager.js';
|
||||
|
||||
/**
|
||||
* SPF result qualifiers
|
||||
*/
|
||||
export enum SpfQualifier {
|
||||
PASS = '+',
|
||||
NEUTRAL = '?',
|
||||
SOFTFAIL = '~',
|
||||
FAIL = '-'
|
||||
}
|
||||
|
||||
/**
|
||||
* SPF mechanism types
|
||||
*/
|
||||
export enum SpfMechanismType {
|
||||
ALL = 'all',
|
||||
INCLUDE = 'include',
|
||||
A = 'a',
|
||||
MX = 'mx',
|
||||
IP4 = 'ip4',
|
||||
IP6 = 'ip6',
|
||||
EXISTS = 'exists',
|
||||
REDIRECT = 'redirect',
|
||||
EXP = 'exp'
|
||||
}
|
||||
|
||||
/**
|
||||
* SPF mechanism definition
|
||||
*/
|
||||
export interface SpfMechanism {
|
||||
qualifier: SpfQualifier;
|
||||
type: SpfMechanismType;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SPF record parsed data
|
||||
*/
|
||||
export interface SpfRecord {
|
||||
version: string;
|
||||
mechanisms: SpfMechanism[];
|
||||
modifiers: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SPF verification result
|
||||
*/
|
||||
export interface SpfResult {
|
||||
result: 'pass' | 'neutral' | 'softfail' | 'fail' | 'temperror' | 'permerror' | 'none';
|
||||
explanation?: string;
|
||||
domain: string;
|
||||
ip: string;
|
||||
record?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum lookup limit for SPF records (prevent infinite loops)
|
||||
*/
|
||||
const MAX_SPF_LOOKUPS = 10;
|
||||
|
||||
/**
|
||||
* Class for verifying SPF records
|
||||
*/
|
||||
export class SpfVerifier {
|
||||
private mtaRef: MtaService;
|
||||
private lookupCount: number = 0;
|
||||
|
||||
constructor(mtaRefArg: MtaService) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SPF record from TXT record
|
||||
* @param record SPF TXT record
|
||||
* @returns Parsed SPF record or null if invalid
|
||||
*/
|
||||
public parseSpfRecord(record: string): SpfRecord | null {
|
||||
if (!record.startsWith('v=spf1')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const spfRecord: SpfRecord = {
|
||||
version: 'spf1',
|
||||
mechanisms: [],
|
||||
modifiers: {}
|
||||
};
|
||||
|
||||
// Split into terms
|
||||
const terms = record.split(' ').filter(term => term.length > 0);
|
||||
|
||||
// Skip version term
|
||||
for (let i = 1; i < terms.length; i++) {
|
||||
const term = terms[i];
|
||||
|
||||
// Check if it's a modifier (name=value)
|
||||
if (term.includes('=')) {
|
||||
const [name, value] = term.split('=');
|
||||
spfRecord.modifiers[name] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse as mechanism
|
||||
let qualifier = SpfQualifier.PASS; // Default is +
|
||||
let mechanismText = term;
|
||||
|
||||
// Check for qualifier
|
||||
if (term.startsWith('+') || term.startsWith('-') ||
|
||||
term.startsWith('~') || term.startsWith('?')) {
|
||||
qualifier = term[0] as SpfQualifier;
|
||||
mechanismText = term.substring(1);
|
||||
}
|
||||
|
||||
// Parse mechanism type and value
|
||||
const colonIndex = mechanismText.indexOf(':');
|
||||
let type: SpfMechanismType;
|
||||
let value: string | undefined;
|
||||
|
||||
if (colonIndex !== -1) {
|
||||
type = mechanismText.substring(0, colonIndex) as SpfMechanismType;
|
||||
value = mechanismText.substring(colonIndex + 1);
|
||||
} else {
|
||||
type = mechanismText as SpfMechanismType;
|
||||
}
|
||||
|
||||
spfRecord.mechanisms.push({ qualifier, type, value });
|
||||
}
|
||||
|
||||
return spfRecord;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error parsing SPF record: ${error.message}`, {
|
||||
record,
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP is in CIDR range
|
||||
* @param ip IP address to check
|
||||
* @param cidr CIDR range
|
||||
* @returns Whether the IP is in the CIDR range
|
||||
*/
|
||||
private isIpInCidr(ip: string, cidr: string): boolean {
|
||||
try {
|
||||
const ipAddress = plugins.ip.Address4.parse(ip);
|
||||
return ipAddress.isInSubnet(new plugins.ip.Address4(cidr));
|
||||
} catch (error) {
|
||||
// Try IPv6
|
||||
try {
|
||||
const ipAddress = plugins.ip.Address6.parse(ip);
|
||||
return ipAddress.isInSubnet(new plugins.ip.Address6(cidr));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain has the specified IP in its A or AAAA records
|
||||
* @param domain Domain to check
|
||||
* @param ip IP address to check
|
||||
* @returns Whether the domain resolves to the IP
|
||||
*/
|
||||
private async isDomainResolvingToIp(domain: string, ip: string): Promise<boolean> {
|
||||
try {
|
||||
// First try IPv4
|
||||
const ipv4Addresses = await plugins.dns.promises.resolve4(domain);
|
||||
if (ipv4Addresses.includes(ip)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then try IPv6
|
||||
const ipv6Addresses = await plugins.dns.promises.resolve6(domain);
|
||||
if (ipv6Addresses.includes(ip)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify SPF for a given email with IP and helo domain
|
||||
* @param email Email to verify
|
||||
* @param ip Sender IP address
|
||||
* @param heloDomain HELO/EHLO domain used by sender
|
||||
* @returns SPF verification result
|
||||
*/
|
||||
public async verify(
|
||||
email: Email,
|
||||
ip: string,
|
||||
heloDomain: string
|
||||
): Promise<SpfResult> {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
|
||||
// Reset lookup count
|
||||
this.lookupCount = 0;
|
||||
|
||||
// Get domain from envelope from (return-path)
|
||||
const domain = email.getEnvelopeFrom().split('@')[1] || '';
|
||||
|
||||
if (!domain) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'No envelope from domain',
|
||||
domain: '',
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Look up SPF record
|
||||
const spfVerificationResult = await this.mtaRef.dnsManager.verifySpfRecord(domain);
|
||||
|
||||
if (!spfVerificationResult.found) {
|
||||
return {
|
||||
result: 'none',
|
||||
explanation: 'No SPF record found',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
if (!spfVerificationResult.valid) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Invalid SPF record',
|
||||
domain,
|
||||
ip,
|
||||
record: spfVerificationResult.value
|
||||
};
|
||||
}
|
||||
|
||||
// Parse SPF record
|
||||
const spfRecord = this.parseSpfRecord(spfVerificationResult.value);
|
||||
|
||||
if (!spfRecord) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Failed to parse SPF record',
|
||||
domain,
|
||||
ip,
|
||||
record: spfVerificationResult.value
|
||||
};
|
||||
}
|
||||
|
||||
// Check SPF record
|
||||
const result = await this.checkSpfRecord(spfRecord, domain, ip);
|
||||
|
||||
// Log the result
|
||||
const spfLogLevel = result.result === 'pass' ?
|
||||
SecurityLogLevel.INFO :
|
||||
(result.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO);
|
||||
|
||||
securityLogger.logEvent({
|
||||
level: spfLogLevel,
|
||||
type: SecurityEventType.SPF,
|
||||
message: `SPF ${result.result} for ${domain} from IP ${ip}`,
|
||||
domain,
|
||||
details: {
|
||||
ip,
|
||||
heloDomain,
|
||||
result: result.result,
|
||||
explanation: result.explanation,
|
||||
record: spfVerificationResult.value
|
||||
},
|
||||
success: result.result === 'pass'
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
domain,
|
||||
ip,
|
||||
record: spfVerificationResult.value
|
||||
};
|
||||
} catch (error) {
|
||||
// Log error
|
||||
logger.log('error', `SPF verification error: ${error.message}`, {
|
||||
domain,
|
||||
ip,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
securityLogger.logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.SPF,
|
||||
message: `SPF verification error for ${domain}`,
|
||||
domain,
|
||||
details: {
|
||||
ip,
|
||||
error: error.message
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return {
|
||||
result: 'temperror',
|
||||
explanation: `Error verifying SPF: ${error.message}`,
|
||||
domain,
|
||||
ip,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SPF record against IP address
|
||||
* @param spfRecord Parsed SPF record
|
||||
* @param domain Domain being checked
|
||||
* @param ip IP address to check
|
||||
* @returns SPF result
|
||||
*/
|
||||
private async checkSpfRecord(
|
||||
spfRecord: SpfRecord,
|
||||
domain: string,
|
||||
ip: string
|
||||
): Promise<SpfResult> {
|
||||
// Check for 'redirect' modifier
|
||||
if (spfRecord.modifiers.redirect) {
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Handle redirect
|
||||
const redirectDomain = spfRecord.modifiers.redirect;
|
||||
const redirectResult = await this.mtaRef.dnsManager.verifySpfRecord(redirectDomain);
|
||||
|
||||
if (!redirectResult.found || !redirectResult.valid) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: `Invalid redirect to ${redirectDomain}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
const redirectRecord = this.parseSpfRecord(redirectResult.value);
|
||||
|
||||
if (!redirectRecord) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: `Failed to parse redirect record from ${redirectDomain}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
return this.checkSpfRecord(redirectRecord, redirectDomain, ip);
|
||||
}
|
||||
|
||||
// Check each mechanism in order
|
||||
for (const mechanism of spfRecord.mechanisms) {
|
||||
let matched = false;
|
||||
|
||||
switch (mechanism.type) {
|
||||
case SpfMechanismType.ALL:
|
||||
matched = true;
|
||||
break;
|
||||
|
||||
case SpfMechanismType.IP4:
|
||||
if (mechanism.value) {
|
||||
matched = this.isIpInCidr(ip, mechanism.value);
|
||||
}
|
||||
break;
|
||||
|
||||
case SpfMechanismType.IP6:
|
||||
if (mechanism.value) {
|
||||
matched = this.isIpInCidr(ip, mechanism.value);
|
||||
}
|
||||
break;
|
||||
|
||||
case SpfMechanismType.A:
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Check if domain has A/AAAA record matching IP
|
||||
const checkDomain = mechanism.value || domain;
|
||||
matched = await this.isDomainResolvingToIp(checkDomain, ip);
|
||||
break;
|
||||
|
||||
case SpfMechanismType.MX:
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Check MX records
|
||||
const mxDomain = mechanism.value || domain;
|
||||
|
||||
try {
|
||||
const mxRecords = await plugins.dns.promises.resolveMx(mxDomain);
|
||||
|
||||
for (const mx of mxRecords) {
|
||||
// Check if this MX record's IP matches
|
||||
const mxMatches = await this.isDomainResolvingToIp(mx.exchange, ip);
|
||||
|
||||
if (mxMatches) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// No MX records or error
|
||||
matched = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case SpfMechanismType.INCLUDE:
|
||||
if (!mechanism.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Check included domain's SPF record
|
||||
const includeDomain = mechanism.value;
|
||||
const includeResult = await this.mtaRef.dnsManager.verifySpfRecord(includeDomain);
|
||||
|
||||
if (!includeResult.found || !includeResult.valid) {
|
||||
continue; // Skip this mechanism
|
||||
}
|
||||
|
||||
const includeRecord = this.parseSpfRecord(includeResult.value);
|
||||
|
||||
if (!includeRecord) {
|
||||
continue; // Skip this mechanism
|
||||
}
|
||||
|
||||
// Recursively check the included SPF record
|
||||
const includeCheck = await this.checkSpfRecord(includeRecord, includeDomain, ip);
|
||||
|
||||
// Include mechanism matches if the result is "pass"
|
||||
matched = includeCheck.result === 'pass';
|
||||
break;
|
||||
|
||||
case SpfMechanismType.EXISTS:
|
||||
if (!mechanism.value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.lookupCount++;
|
||||
|
||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
||||
return {
|
||||
result: 'permerror',
|
||||
explanation: 'Too many DNS lookups',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
// Check if domain exists (has any A record)
|
||||
try {
|
||||
await plugins.dns.promises.resolve(mechanism.value, 'A');
|
||||
matched = true;
|
||||
} catch (error) {
|
||||
matched = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// If this mechanism matched, return its result
|
||||
if (matched) {
|
||||
switch (mechanism.qualifier) {
|
||||
case SpfQualifier.PASS:
|
||||
return {
|
||||
result: 'pass',
|
||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
case SpfQualifier.FAIL:
|
||||
return {
|
||||
result: 'fail',
|
||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
case SpfQualifier.SOFTFAIL:
|
||||
return {
|
||||
result: 'softfail',
|
||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
case SpfQualifier.NEUTRAL:
|
||||
return {
|
||||
result: 'neutral',
|
||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no mechanism matched, default to neutral
|
||||
return {
|
||||
result: 'neutral',
|
||||
explanation: 'No matching mechanism found',
|
||||
domain,
|
||||
ip
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email passes SPF verification
|
||||
* @param email Email to verify
|
||||
* @param ip Sender IP address
|
||||
* @param heloDomain HELO/EHLO domain used by sender
|
||||
* @returns Whether email passes SPF
|
||||
*/
|
||||
public async verifyAndApply(
|
||||
email: Email,
|
||||
ip: string,
|
||||
heloDomain: string
|
||||
): Promise<boolean> {
|
||||
const result = await this.verify(email, ip, heloDomain);
|
||||
|
||||
// Add headers
|
||||
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
|
||||
|
||||
// Apply policy based on result
|
||||
switch (result.result) {
|
||||
case 'fail':
|
||||
// Fail - mark as spam
|
||||
email.mightBeSpam = true;
|
||||
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return false;
|
||||
|
||||
case 'softfail':
|
||||
// Soft fail - accept but mark as suspicious
|
||||
email.mightBeSpam = true;
|
||||
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
case 'neutral':
|
||||
case 'none':
|
||||
// Neutral or none - accept but note in headers
|
||||
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
case 'pass':
|
||||
// Pass - accept
|
||||
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
case 'temperror':
|
||||
case 'permerror':
|
||||
// Temporary or permanent error - log but accept
|
||||
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export * from './classes.dkimcreator.js';
|
||||
export * from './classes.emailsignjob.js';
|
||||
export * from './classes.dkimverifier.js';
|
||||
export * from './classes.dmarcverifier.js';
|
||||
export * from './classes.spfverifier.js';
|
||||
export * from './classes.mta.js';
|
||||
export * from './classes.smtpserver.js';
|
||||
export * from './classes.emailsendjob.js';
|
||||
export * from './classes.email.js';
|
||||
export * from './classes.ratelimiter.js';
|
||||
69
ts/opsserver/classes.opsserver.ts
Normal file
69
ts/opsserver/classes.opsserver.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type DcRouter from '../classes.dcrouter.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import * as handlers from './handlers/index.js';
|
||||
|
||||
export class OpsServer {
|
||||
public dcRouterRef: DcRouter;
|
||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||
|
||||
// TypedRouter for OpsServer-specific handlers
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Handler instances
|
||||
public adminHandler: handlers.AdminHandler;
|
||||
private configHandler: handlers.ConfigHandler;
|
||||
private logsHandler: handlers.LogsHandler;
|
||||
private securityHandler: handlers.SecurityHandler;
|
||||
private statsHandler: handlers.StatsHandler;
|
||||
private radiusHandler: handlers.RadiusHandler;
|
||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
|
||||
// Add our typedrouter to the dcRouter's main typedrouter
|
||||
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||
domain: 'localhost',
|
||||
feedMetadata: null,
|
||||
serveDir: paths.distServe,
|
||||
});
|
||||
|
||||
// The server has a built-in typedrouter at /typedrequest
|
||||
// Add the main dcRouter typedrouter to the server's typedrouter
|
||||
this.server.typedrouter.addTypedRouter(this.dcRouterRef.typedrouter);
|
||||
|
||||
// Set up handlers
|
||||
await this.setupHandlers();
|
||||
|
||||
await this.server.start(3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all TypedRequest handlers
|
||||
*/
|
||||
private async setupHandlers(): Promise<void> {
|
||||
// Instantiate all handlers - they self-register with the typedrouter
|
||||
this.adminHandler = new handlers.AdminHandler(this);
|
||||
await this.adminHandler.initialize(); // JWT needs async initialization
|
||||
|
||||
this.configHandler = new handlers.ConfigHandler(this);
|
||||
this.logsHandler = new handlers.LogsHandler(this);
|
||||
this.securityHandler = new handlers.SecurityHandler(this);
|
||||
this.statsHandler = new handlers.StatsHandler(this);
|
||||
this.radiusHandler = new handlers.RadiusHandler(this);
|
||||
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
240
ts/opsserver/handlers/admin.handler.ts
Normal file
240
ts/opsserver/handlers/admin.handler.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export interface IJwtData {
|
||||
userId: string;
|
||||
status: 'loggedIn' | 'loggedOut';
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class AdminHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// JWT instance
|
||||
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
|
||||
// Simple in-memory user storage (in production, use proper database)
|
||||
private users = new Map<string, {
|
||||
id: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}>();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.initializeJwt();
|
||||
this.initializeDefaultUsers();
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async initializeJwt(): Promise<void> {
|
||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||
await this.smartjwtInstance.init();
|
||||
|
||||
// For development, create new keypair each time
|
||||
// In production, load from storage like cloudly does
|
||||
await this.smartjwtInstance.createNewKeyPair();
|
||||
}
|
||||
|
||||
private initializeDefaultUsers(): void {
|
||||
// Add default admin user
|
||||
const adminId = plugins.uuid.v4();
|
||||
this.users.set(adminId, {
|
||||
id: adminId,
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
role: 'admin',
|
||||
});
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Admin Login Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
// Find user by username and password
|
||||
let user: { id: string; username: string; password: string; role: string } | null = null;
|
||||
for (const [_, userData] of this.users) {
|
||||
if (userData.username === dataArg.username && userData.password === dataArg.password) {
|
||||
user = userData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||
}
|
||||
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId: user.id,
|
||||
status: 'loggedIn',
|
||||
expiresAt: expiresAtTimestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: expiresAtTimestamp,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||
throw error;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Admin Logout Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
|
||||
'adminLogout',
|
||||
async (dataArg) => {
|
||||
// In a real implementation, you might want to blacklist the JWT
|
||||
// For now, just return success
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Verify Identity Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'verifyIdentity',
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
|
||||
// Check if expired
|
||||
if (jwtData.expiresAt < Date.now()) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if logged in
|
||||
if (jwtData.status !== 'loggedIn') {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = this.users.get(jwtData.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a guard for valid identity (matching cloudly pattern)
|
||||
*/
|
||||
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
|
||||
// Check expiration
|
||||
if (jwtData.expiresAt < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check status
|
||||
if (jwtData.status !== 'loggedIn') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify data hasn't been tampered with
|
||||
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataArg.identity.userId !== jwtData.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
failedHint: 'identity is not valid',
|
||||
name: 'validIdentityGuard',
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a guard for admin identity (matching cloudly pattern)
|
||||
*/
|
||||
public adminIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
// First check if identity is valid
|
||||
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
return dataArg.identity.role === 'admin';
|
||||
},
|
||||
{
|
||||
failedHint: 'user is not admin',
|
||||
name: 'adminIdentityGuard',
|
||||
}
|
||||
);
|
||||
}
|
||||
108
ts/opsserver/handlers/config.handler.ts
Normal file
108
ts/opsserver/handlers/config.handler.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class ConfigHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Configuration Handler (read-only)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
async (dataArg, toolsArg) => {
|
||||
const config = await this.getConfiguration(dataArg.section);
|
||||
return {
|
||||
config,
|
||||
section: dataArg.section,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfiguration(section?: string): Promise<{
|
||||
email: {
|
||||
enabled: boolean;
|
||||
ports: number[];
|
||||
maxMessageSize: number;
|
||||
rateLimits: {
|
||||
perMinute: number;
|
||||
perHour: number;
|
||||
perDay: number;
|
||||
};
|
||||
domains?: string[];
|
||||
};
|
||||
dns: {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
nameservers: string[];
|
||||
caching: boolean;
|
||||
ttl: number;
|
||||
};
|
||||
proxy: {
|
||||
enabled: boolean;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
maxConnections: number;
|
||||
};
|
||||
security: {
|
||||
blockList: string[];
|
||||
rateLimit: boolean;
|
||||
spamDetection: boolean;
|
||||
tlsRequired: boolean;
|
||||
};
|
||||
}> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
|
||||
// Get email domains if email server is configured
|
||||
let emailDomains: string[] = [];
|
||||
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) {
|
||||
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains();
|
||||
} else if (dcRouter.options.emailConfig?.domains) {
|
||||
// Fallback: get domains from email config options
|
||||
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
|
||||
typeof d === 'string' ? d : d.domain
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
email: {
|
||||
enabled: !!dcRouter.emailServer,
|
||||
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [],
|
||||
maxMessageSize: 10 * 1024 * 1024, // 10MB default
|
||||
rateLimits: {
|
||||
perMinute: 10,
|
||||
perHour: 100,
|
||||
perDay: 1000,
|
||||
},
|
||||
domains: emailDomains,
|
||||
},
|
||||
dns: {
|
||||
enabled: !!dcRouter.dnsServer,
|
||||
port: 53,
|
||||
nameservers: dcRouter.options.dnsNsDomains || [],
|
||||
caching: true,
|
||||
ttl: 300,
|
||||
},
|
||||
proxy: {
|
||||
enabled: !!dcRouter.smartProxy,
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
maxConnections: 1000,
|
||||
},
|
||||
security: {
|
||||
blockList: [],
|
||||
rateLimit: true,
|
||||
spamDetection: true,
|
||||
tlsRequired: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
318
ts/opsserver/handlers/email-ops.handler.ts
Normal file
318
ts/opsserver/handlers/email-ops.handler.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { SecurityLogger } from '../../security/index.js';
|
||||
|
||||
export class EmailOpsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Queued Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
|
||||
'getQueuedEmails',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const stats = queue.getStats();
|
||||
|
||||
// Get all queue items and filter by status if provided
|
||||
const items = this.getQueueItems(
|
||||
dataArg.status,
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: stats.queueSize,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Sent Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
|
||||
'getSentEmails',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'delivered',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length, // Note: total would ideally come from a counter
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Failed Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
|
||||
'getFailedEmails',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'failed',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Resend Failed Email Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return { success: false, error: 'Email server not available' };
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(dataArg.emailId);
|
||||
|
||||
if (!item) {
|
||||
return { success: false, error: 'Email not found in queue' };
|
||||
}
|
||||
|
||||
if (item.status !== 'failed') {
|
||||
return { success: false, error: `Email is not in failed state (current: ${item.status})` };
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-enqueue the failed email by creating a new queue entry
|
||||
// with the same data but reset attempt count
|
||||
const newQueueId = await queue.enqueue(
|
||||
item.processingResult,
|
||||
item.processingMode,
|
||||
item.route
|
||||
);
|
||||
|
||||
// Optionally remove the old failed entry
|
||||
await queue.removeItem(dataArg.emailId);
|
||||
|
||||
return { success: true, newQueueId };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to resend email'
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Security Incidents Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
|
||||
'getSecurityIncidents',
|
||||
async (dataArg) => {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
|
||||
const filter: {
|
||||
level?: any;
|
||||
type?: any;
|
||||
} = {};
|
||||
|
||||
if (dataArg.level) {
|
||||
filter.level = dataArg.level;
|
||||
}
|
||||
|
||||
if (dataArg.type) {
|
||||
filter.type = dataArg.type;
|
||||
}
|
||||
|
||||
const incidents = securityLogger.getRecentEvents(
|
||||
dataArg.limit || 100,
|
||||
Object.keys(filter).length > 0 ? filter : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
incidents: incidents.map(event => ({
|
||||
timestamp: event.timestamp,
|
||||
level: event.level as interfaces.requests.TSecurityLogLevel,
|
||||
type: event.type as interfaces.requests.TSecurityEventType,
|
||||
message: event.message,
|
||||
details: event.details,
|
||||
ipAddress: event.ipAddress,
|
||||
userId: event.userId,
|
||||
sessionId: event.sessionId,
|
||||
emailId: event.emailId,
|
||||
domain: event.domain,
|
||||
action: event.action,
|
||||
result: event.result,
|
||||
success: event.success,
|
||||
})),
|
||||
total: incidents.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Bounce Records Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
|
||||
'getBounceRecords',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
|
||||
if (!emailServer) {
|
||||
return { records: [], suppressionList: [], total: 0 };
|
||||
}
|
||||
|
||||
// Use smartmta's public API for bounce/suppression data
|
||||
const suppressionList = emailServer.getSuppressionList();
|
||||
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
|
||||
|
||||
// Create bounce records from the available data
|
||||
const records: interfaces.requests.IBounceRecord[] = [];
|
||||
|
||||
for (const email of hardBouncedAddresses) {
|
||||
const bounceInfo = emailServer.getBounceHistory(email);
|
||||
if (bounceInfo) {
|
||||
records.push({
|
||||
id: `bounce-${email}`,
|
||||
recipient: email,
|
||||
sender: '',
|
||||
domain: email.split('@')[1] || '',
|
||||
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
|
||||
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
|
||||
timestamp: (bounceInfo as any).lastBounce,
|
||||
processed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit and offset
|
||||
const limit = dataArg.limit || 50;
|
||||
const offset = dataArg.offset || 0;
|
||||
const paginatedRecords = records.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
records: paginatedRecords,
|
||||
suppressionList,
|
||||
total: records.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove from Suppression List Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
|
||||
'removeFromSuppressionList',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
|
||||
if (!emailServer) {
|
||||
return { success: false, error: 'Email server not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
emailServer.removeFromSuppressionList(dataArg.email);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get queue items with filtering and pagination
|
||||
*/
|
||||
private getQueueItems(
|
||||
status?: interfaces.requests.TEmailQueueStatus,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): interfaces.requests.IEmailQueueItem[] {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const items: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
// Access the internal queue map via reflection
|
||||
// This is necessary because the queue doesn't expose iteration methods
|
||||
const queueMap = (queue as any).queue as Map<string, any>;
|
||||
|
||||
if (!queueMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter and convert items
|
||||
for (const [id, item] of queueMap.entries()) {
|
||||
// Apply status filter if provided
|
||||
if (status && item.status !== status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email details from processingResult if available
|
||||
const processingResult = item.processingResult;
|
||||
let from = '';
|
||||
let to: string[] = [];
|
||||
let subject = '';
|
||||
|
||||
if (processingResult) {
|
||||
// Check if it's an Email object or raw email data
|
||||
if (processingResult.email) {
|
||||
from = processingResult.email.from || '';
|
||||
to = processingResult.email.to || [];
|
||||
subject = processingResult.email.subject || '';
|
||||
} else if (processingResult.from) {
|
||||
from = processingResult.from;
|
||||
to = processingResult.to || [];
|
||||
subject = processingResult.subject || '';
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: item.id,
|
||||
processingMode: item.processingMode,
|
||||
status: item.status,
|
||||
attempts: item.attempts,
|
||||
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
|
||||
lastError: item.lastError,
|
||||
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
|
||||
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
|
||||
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
// Apply pagination
|
||||
return items.slice(offset, offset + limit);
|
||||
}
|
||||
}
|
||||
7
ts/opsserver/handlers/index.ts
Normal file
7
ts/opsserver/handlers/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './admin.handler.js';
|
||||
export * from './config.handler.js';
|
||||
export * from './logs.handler.js';
|
||||
export * from './security.handler.js';
|
||||
export * from './stats.handler.js';
|
||||
export * from './radius.handler.js';
|
||||
export * from './email-ops.handler.js';
|
||||
195
ts/opsserver/handlers/logs.handler.ts
Normal file
195
ts/opsserver/handlers/logs.handler.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class LogsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Recent Logs Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'getRecentLogs',
|
||||
async (dataArg, toolsArg) => {
|
||||
const logs = await this.getRecentLogs(
|
||||
dataArg.level,
|
||||
dataArg.category,
|
||||
dataArg.limit || 100,
|
||||
dataArg.offset || 0,
|
||||
dataArg.search,
|
||||
dataArg.timeRange
|
||||
);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: logs.length, // TODO: Implement proper total count
|
||||
hasMore: false, // TODO: Implement proper pagination
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Log Stream Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
||||
'getLogStream',
|
||||
async (dataArg, toolsArg) => {
|
||||
// Create a virtual stream for log streaming
|
||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||
|
||||
// Set up log streaming
|
||||
const streamLogs = this.setupLogStream(
|
||||
virtualStream,
|
||||
dataArg.filters?.level,
|
||||
dataArg.filters?.category,
|
||||
dataArg.follow
|
||||
);
|
||||
|
||||
// Start streaming
|
||||
streamLogs.start();
|
||||
|
||||
// VirtualStream handles cleanup automatically
|
||||
|
||||
return {
|
||||
logStream: virtualStream as any, // Cast to IVirtualStream interface
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getRecentLogs(
|
||||
level?: 'error' | 'warn' | 'info' | 'debug',
|
||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
|
||||
limit: number = 100,
|
||||
offset: number = 0,
|
||||
search?: string,
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d'
|
||||
): Promise<Array<{
|
||||
timestamp: number;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
message: string;
|
||||
metadata?: any;
|
||||
}>> {
|
||||
// TODO: Implement actual log retrieval from storage or logger
|
||||
// For now, return mock data
|
||||
const mockLogs: Array<{
|
||||
timestamp: number;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
message: string;
|
||||
metadata?: any;
|
||||
}> = [];
|
||||
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
const now = Date.now();
|
||||
|
||||
// Generate some mock log entries
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||
|
||||
// Filter by requested criteria
|
||||
if (level && mockLevel !== level) continue;
|
||||
if (category && mockCategory !== category) continue;
|
||||
|
||||
mockLogs.push({
|
||||
timestamp: now - (i * 60000), // 1 minute apart
|
||||
level: mockLevel,
|
||||
category: mockCategory,
|
||||
message: `Sample log message ${i} from ${mockCategory}`,
|
||||
metadata: {
|
||||
requestId: plugins.uuid.v4(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
return mockLogs.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
private setupLogStream(
|
||||
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
|
||||
levelFilter?: string[],
|
||||
categoryFilter?: string[],
|
||||
follow: boolean = true
|
||||
): {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
} {
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
let logIndex = 0;
|
||||
|
||||
const start = () => {
|
||||
if (!follow) {
|
||||
// Send existing logs and close
|
||||
this.getRecentLogs(
|
||||
levelFilter?.[0] as any,
|
||||
categoryFilter?.[0] as any,
|
||||
100,
|
||||
0
|
||||
).then(logs => {
|
||||
logs.forEach(log => {
|
||||
const logData = JSON.stringify(log);
|
||||
const encoder = new TextEncoder();
|
||||
virtualStream.sendData(encoder.encode(logData));
|
||||
});
|
||||
// VirtualStream doesn't have end() method - it closes automatically
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For follow mode, simulate real-time log streaming
|
||||
intervalId = setInterval(() => {
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
|
||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||
|
||||
// Filter by requested criteria
|
||||
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
||||
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
||||
|
||||
const logEntry = {
|
||||
timestamp: Date.now(),
|
||||
level: mockLevel,
|
||||
category: mockCategory,
|
||||
message: `Real-time log ${logIndex++} from ${mockCategory}`,
|
||||
metadata: {
|
||||
requestId: plugins.uuid.v4(),
|
||||
},
|
||||
};
|
||||
|
||||
const logData = JSON.stringify(logEntry);
|
||||
const encoder = new TextEncoder();
|
||||
virtualStream.sendData(encoder.encode(logData));
|
||||
}, 2000); // Send a log every 2 seconds
|
||||
|
||||
// TODO: Hook into actual logger events
|
||||
// logger.on('log', (logEntry) => {
|
||||
// if (matchesCriteria(logEntry, level, service)) {
|
||||
// virtualStream.sendData(formatLogEntry(logEntry));
|
||||
// }
|
||||
// });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
// TODO: Unhook from logger events
|
||||
};
|
||||
|
||||
return { start, stop };
|
||||
}
|
||||
}
|
||||
405
ts/opsserver/handlers/radius.handler.ts
Normal file
405
ts/opsserver/handlers/radius.handler.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class RadiusHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// ========================================================================
|
||||
// RADIUS Client Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all RADIUS clients
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||
'getRadiusClients',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { clients: [] };
|
||||
}
|
||||
|
||||
const clients = radiusServer.getClients();
|
||||
return {
|
||||
clients: clients.map(c => ({
|
||||
name: c.name,
|
||||
ipRange: c.ipRange,
|
||||
description: c.description,
|
||||
enabled: c.enabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||
'setRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
await radiusServer.addClient(dataArg.client);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||
'removeRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const removed = radiusServer.removeClient(dataArg.name);
|
||||
return {
|
||||
success: removed,
|
||||
message: removed ? undefined : 'Client not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// VLAN Mapping Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all VLAN mappings
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||
'getVlanMappings',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
mappings: [],
|
||||
config: { defaultVlan: 1, allowUnknownMacs: true },
|
||||
};
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const mappings = vlanManager.getAllMappings();
|
||||
const config = vlanManager.getConfig();
|
||||
|
||||
return {
|
||||
mappings: mappings.map(m => ({
|
||||
mac: m.mac,
|
||||
vlan: m.vlan,
|
||||
description: m.description,
|
||||
enabled: m.enabled,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
})),
|
||||
config: {
|
||||
defaultVlan: config.defaultVlan,
|
||||
allowUnknownMacs: config.allowUnknownMacs,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||
'setVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const mapping = await vlanManager.addMapping(dataArg.mapping);
|
||||
return {
|
||||
success: true,
|
||||
mapping: {
|
||||
mac: mapping.mac,
|
||||
vlan: mapping.vlan,
|
||||
description: mapping.description,
|
||||
enabled: mapping.enabled,
|
||||
createdAt: mapping.createdAt,
|
||||
updatedAt: mapping.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||
'removeVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const removed = await vlanManager.removeMapping(dataArg.mac);
|
||||
return {
|
||||
success: removed,
|
||||
message: removed ? undefined : 'Mapping not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Update VLAN configuration
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||
'updateVlanConfig',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
success: false,
|
||||
config: { defaultVlan: 1, allowUnknownMacs: true },
|
||||
};
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
vlanManager.updateConfig({
|
||||
defaultVlan: dataArg.defaultVlan,
|
||||
allowUnknownMacs: dataArg.allowUnknownMacs,
|
||||
});
|
||||
|
||||
const config = vlanManager.getConfig();
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
defaultVlan: config.defaultVlan,
|
||||
allowUnknownMacs: config.allowUnknownMacs,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Test VLAN assignment
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||
'testVlanAssignment',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { assigned: false, vlan: 0, isDefault: false };
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const result = vlanManager.assignVlan(dataArg.mac);
|
||||
|
||||
return {
|
||||
assigned: result.assigned,
|
||||
vlan: result.vlan,
|
||||
isDefault: result.isDefault,
|
||||
matchedRule: result.matchedRule
|
||||
? {
|
||||
mac: result.matchedRule.mac,
|
||||
vlan: result.matchedRule.vlan,
|
||||
description: result.matchedRule.description,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// Accounting / Session Management
|
||||
// ========================================================================
|
||||
|
||||
// Get active sessions
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||
'getRadiusSessions',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { sessions: [], totalCount: 0 };
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
let sessions = accountingManager.getActiveSessions();
|
||||
|
||||
// Apply filters
|
||||
if (dataArg.filter) {
|
||||
if (dataArg.filter.username) {
|
||||
sessions = sessions.filter(s => s.username === dataArg.filter!.username);
|
||||
}
|
||||
if (dataArg.filter.nasIpAddress) {
|
||||
sessions = sessions.filter(s => s.nasIpAddress === dataArg.filter!.nasIpAddress);
|
||||
}
|
||||
if (dataArg.filter.vlanId !== undefined) {
|
||||
sessions = sessions.filter(s => s.vlanId === dataArg.filter!.vlanId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: sessions.map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
username: s.username,
|
||||
macAddress: s.macAddress,
|
||||
nasIpAddress: s.nasIpAddress,
|
||||
nasIdentifier: s.nasIdentifier,
|
||||
vlanId: s.vlanId,
|
||||
framedIpAddress: s.framedIpAddress,
|
||||
startTime: s.startTime,
|
||||
lastUpdateTime: s.lastUpdateTime,
|
||||
status: s.status,
|
||||
inputOctets: s.inputOctets,
|
||||
outputOctets: s.outputOctets,
|
||||
sessionTime: s.sessionTime,
|
||||
})),
|
||||
totalCount: sessions.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Disconnect a session
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||
'disconnectRadiusSession',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
const disconnected = await accountingManager.disconnectSession(
|
||||
dataArg.sessionId,
|
||||
dataArg.reason || 'AdminReset'
|
||||
);
|
||||
|
||||
return {
|
||||
success: disconnected,
|
||||
message: disconnected ? undefined : 'Session not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get accounting summary
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||
'getRadiusAccountingSummary',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
summary: {
|
||||
periodStart: dataArg.startTime,
|
||||
periodEnd: dataArg.endTime,
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalInputBytes: 0,
|
||||
totalOutputBytes: 0,
|
||||
totalSessionTime: 0,
|
||||
averageSessionDuration: 0,
|
||||
uniqueUsers: 0,
|
||||
sessionsByVlan: {},
|
||||
topUsersByTraffic: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
const summary = await accountingManager.getSummary(dataArg.startTime, dataArg.endTime);
|
||||
|
||||
return { summary };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// Statistics
|
||||
// ========================================================================
|
||||
|
||||
// Get RADIUS statistics
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||
'getRadiusStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
stats: {
|
||||
running: false,
|
||||
uptime: 0,
|
||||
authRequests: 0,
|
||||
authAccepts: 0,
|
||||
authRejects: 0,
|
||||
accountingRequests: 0,
|
||||
activeSessions: 0,
|
||||
vlanMappings: 0,
|
||||
clients: 0,
|
||||
},
|
||||
vlanStats: {
|
||||
totalMappings: 0,
|
||||
enabledMappings: 0,
|
||||
exactMatches: 0,
|
||||
ouiPatterns: 0,
|
||||
wildcardPatterns: 0,
|
||||
},
|
||||
accountingStats: {
|
||||
activeSessions: 0,
|
||||
totalSessionsStarted: 0,
|
||||
totalSessionsStopped: 0,
|
||||
totalInputBytes: 0,
|
||||
totalOutputBytes: 0,
|
||||
interimUpdatesReceived: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stats = radiusServer.getStats();
|
||||
const vlanStats = radiusServer.getVlanManager().getStats();
|
||||
const accountingStats = radiusServer.getAccountingManager().getStats();
|
||||
|
||||
return {
|
||||
stats,
|
||||
vlanStats,
|
||||
accountingStats,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
317
ts/opsserver/handlers/security.handler.ts
Normal file
317
ts/opsserver/handlers/security.handler.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
|
||||
export class SecurityHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Security Metrics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||
'getSecurityMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const metrics = await this.collectSecurityMetrics();
|
||||
return {
|
||||
metrics: {
|
||||
blockedIPs: metrics.blockedIPs,
|
||||
reputationScores: metrics.reputationScores,
|
||||
spamDetected: metrics.spamDetection.detected,
|
||||
malwareDetected: metrics.malwareDetected,
|
||||
phishingDetected: metrics.phishingDetected,
|
||||
authenticationFailures: metrics.authFailures,
|
||||
suspiciousActivities: metrics.suspiciousActivities,
|
||||
},
|
||||
trends: dataArg.includeDetails ? {
|
||||
spam: metrics.trends.spam,
|
||||
malware: metrics.trends.malware,
|
||||
phishing: metrics.trends.phishing,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Active Connections Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
||||
'getActiveConnections',
|
||||
async (dataArg, toolsArg) => {
|
||||
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
|
||||
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
|
||||
id: conn.id,
|
||||
remoteAddress: conn.source.ip,
|
||||
localAddress: conn.destination.ip,
|
||||
startTime: conn.startTime,
|
||||
protocol: conn.type === 'http' ? 'https' : conn.type as any,
|
||||
state: conn.status as any,
|
||||
bytesReceived: Math.floor(conn.bytesTransferred / 2),
|
||||
bytesSent: Math.floor(conn.bytesTransferred / 2),
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
total: connectionInfos.length,
|
||||
byProtocol: connectionInfos.reduce((acc, conn) => {
|
||||
acc[conn.protocol] = (acc[conn.protocol] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as { [protocol: string]: number }),
|
||||
byState: connectionInfos.reduce((acc, conn) => {
|
||||
acc[conn.state] = (acc[conn.state] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as { [state: string]: number }),
|
||||
};
|
||||
|
||||
return {
|
||||
connections: connectionInfos,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Network Stats Handler - provides comprehensive network metrics
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler(
|
||||
'getNetworkStats',
|
||||
async (dataArg, toolsArg) => {
|
||||
// Get network stats from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
|
||||
return {
|
||||
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
||||
throughputRate: networkStats.throughputRate,
|
||||
topIPs: networkStats.topIPs,
|
||||
totalDataTransferred: networkStats.totalDataTransferred,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
connectionsByIP: [],
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Rate Limit Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||
'getRateLimitStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
const status = await this.getRateLimitStatus(dataArg.domain, dataArg.ip);
|
||||
const limits: interfaces.data.IRateLimitInfo[] = status.limits.map(limit => ({
|
||||
domain: limit.identifier,
|
||||
currentRate: limit.current,
|
||||
limit: limit.limit,
|
||||
remaining: limit.limit - limit.current,
|
||||
resetTime: limit.resetAt,
|
||||
blocked: limit.status === 'limited',
|
||||
}));
|
||||
|
||||
return {
|
||||
limits,
|
||||
globalLimit: dataArg.includeBlocked ? {
|
||||
current: limits.reduce((sum, l) => sum + l.currentRate, 0),
|
||||
limit: 1000, // Global limit
|
||||
remaining: 1000 - limits.reduce((sum, l) => sum + l.currentRate, 0),
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async collectSecurityMetrics(): Promise<{
|
||||
blockedIPs: string[];
|
||||
reputationScores: { [domain: string]: number };
|
||||
spamDetection: {
|
||||
detected: number;
|
||||
falsePositives: number;
|
||||
};
|
||||
malwareDetected: number;
|
||||
phishingDetected: number;
|
||||
authFailures: number;
|
||||
suspiciousActivities: number;
|
||||
trends: {
|
||||
spam: Array<{ timestamp: number; value: number }>;
|
||||
malware: Array<{ timestamp: number; value: number }>;
|
||||
phishing: Array<{ timestamp: number; value: number }>;
|
||||
};
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const securityStats = await this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats();
|
||||
return {
|
||||
blockedIPs: [], // TODO: Track actual blocked IPs
|
||||
reputationScores: {},
|
||||
spamDetection: {
|
||||
detected: securityStats.spamDetected,
|
||||
falsePositives: 0,
|
||||
},
|
||||
malwareDetected: securityStats.malwareDetected,
|
||||
phishingDetected: securityStats.phishingDetected,
|
||||
authFailures: securityStats.authFailures,
|
||||
suspiciousActivities: 0,
|
||||
trends: {
|
||||
spam: [],
|
||||
malware: [],
|
||||
phishing: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
blockedIPs: [],
|
||||
reputationScores: {},
|
||||
spamDetection: {
|
||||
detected: 0,
|
||||
falsePositives: 0,
|
||||
},
|
||||
malwareDetected: 0,
|
||||
phishingDetected: 0,
|
||||
authFailures: 0,
|
||||
suspiciousActivities: 0,
|
||||
trends: {
|
||||
spam: [],
|
||||
malware: [],
|
||||
phishing: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getActiveConnections(
|
||||
protocol?: 'http' | 'https' | 'smtp' | 'smtps',
|
||||
state?: string
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
type: 'http' | 'smtp' | 'dns';
|
||||
source: {
|
||||
ip: string;
|
||||
port: number;
|
||||
country?: string;
|
||||
};
|
||||
destination: {
|
||||
ip: string;
|
||||
port: number;
|
||||
service?: string;
|
||||
};
|
||||
startTime: number;
|
||||
bytesTransferred: number;
|
||||
status: 'active' | 'idle' | 'closing';
|
||||
}>> {
|
||||
const connections: Array<{
|
||||
id: string;
|
||||
type: 'http' | 'smtp' | 'dns';
|
||||
source: {
|
||||
ip: string;
|
||||
port: number;
|
||||
country?: string;
|
||||
};
|
||||
destination: {
|
||||
ip: string;
|
||||
port: number;
|
||||
service?: string;
|
||||
};
|
||||
startTime: number;
|
||||
bytesTransferred: number;
|
||||
status: 'active' | 'idle' | 'closing';
|
||||
}> = [];
|
||||
|
||||
// Get connection info and network stats from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
|
||||
// Use IP-based connection data from the new metrics API
|
||||
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
|
||||
let connIndex = 0;
|
||||
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
|
||||
|
||||
for (const [ip, count] of networkStats.connectionsByIP) {
|
||||
// Create a connection entry for each active IP connection
|
||||
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
|
||||
connections.push({
|
||||
id: `conn-${connIndex++}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: ip,
|
||||
port: Math.floor(Math.random() * 50000) + 10000, // High port range
|
||||
},
|
||||
destination: {
|
||||
ip: publicIp,
|
||||
port: 443,
|
||||
service: 'proxy',
|
||||
},
|
||||
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
|
||||
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (connectionInfo.length > 0) {
|
||||
// Fallback to route-based connection info if no IP data available
|
||||
connectionInfo.forEach((info, index) => {
|
||||
connections.push({
|
||||
id: `conn-${index}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: 'unknown',
|
||||
port: 0,
|
||||
},
|
||||
destination: {
|
||||
ip: this.opsServerRef.dcRouterRef.options.publicIp || 'server',
|
||||
port: 443,
|
||||
service: info.source,
|
||||
},
|
||||
startTime: info.lastActivity.getTime(),
|
||||
bytesTransferred: 0,
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by protocol if specified
|
||||
if (protocol) {
|
||||
return connections.filter(conn => {
|
||||
if (protocol === 'https' || protocol === 'http') {
|
||||
return conn.type === 'http';
|
||||
}
|
||||
return conn.type === protocol.replace('s', ''); // smtp/smtps -> smtp
|
||||
});
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
private async getRateLimitStatus(
|
||||
domain?: string,
|
||||
ip?: string
|
||||
): Promise<{
|
||||
limits: Array<{
|
||||
identifier: string;
|
||||
type: 'ip' | 'domain' | 'email';
|
||||
limit: number;
|
||||
current: number;
|
||||
resetAt: number;
|
||||
status: 'ok' | 'warning' | 'limited';
|
||||
}>;
|
||||
}> {
|
||||
// TODO: Implement actual rate limit status collection
|
||||
return {
|
||||
limits: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
503
ts/opsserver/handlers/stats.handler.ts
Normal file
503
ts/opsserver/handlers/stats.handler.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
|
||||
export class StatsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Server Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'getServerStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const stats = await this.collectServerStats();
|
||||
return {
|
||||
stats: {
|
||||
uptime: stats.uptime,
|
||||
startTime: Date.now() - (stats.uptime * 1000),
|
||||
memoryUsage: stats.memoryUsage,
|
||||
cpuUsage: stats.cpuUsage,
|
||||
activeConnections: stats.activeConnections,
|
||||
totalConnections: stats.totalConnections,
|
||||
},
|
||||
history: dataArg.includeHistory ? stats.history : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Email Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
||||
'getEmailStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer) {
|
||||
return {
|
||||
stats: {
|
||||
sent: 0,
|
||||
received: 0,
|
||||
bounced: 0,
|
||||
queued: 0,
|
||||
failed: 0,
|
||||
averageDeliveryTime: 0,
|
||||
deliveryRate: 0,
|
||||
bounceRate: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await this.collectEmailStats();
|
||||
return {
|
||||
stats: {
|
||||
sent: stats.sentToday,
|
||||
received: stats.receivedToday,
|
||||
bounced: Math.floor(stats.sentToday * stats.bounceRate / 100),
|
||||
queued: stats.queueSize,
|
||||
failed: 0,
|
||||
averageDeliveryTime: 0,
|
||||
deliveryRate: stats.deliveryRate,
|
||||
bounceRate: stats.bounceRate,
|
||||
},
|
||||
domainBreakdown: dataArg.includeDetails ? stats.domainBreakdown : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// DNS Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
||||
'getDnsStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const dnsServer = this.opsServerRef.dcRouterRef.dnsServer;
|
||||
if (!dnsServer) {
|
||||
return {
|
||||
stats: {
|
||||
totalQueries: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
cacheHitRate: 0,
|
||||
activeDomains: 0,
|
||||
averageResponseTime: 0,
|
||||
queryTypes: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await this.collectDnsStats();
|
||||
return {
|
||||
stats: {
|
||||
totalQueries: stats.totalQueries,
|
||||
cacheHits: stats.cacheHits,
|
||||
cacheMisses: stats.cacheMisses,
|
||||
cacheHitRate: stats.cacheHitRate,
|
||||
activeDomains: stats.topDomains.length,
|
||||
averageResponseTime: 0,
|
||||
queryTypes: stats.queryTypes,
|
||||
},
|
||||
domainBreakdown: dataArg.includeQueryTypes ? stats.domainBreakdown : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Queue Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
||||
'getQueueStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
const queues: interfaces.data.IQueueStatus[] = [];
|
||||
|
||||
if (emailServer) {
|
||||
const status = await this.getQueueStatus();
|
||||
queues.push({
|
||||
name: dataArg.queueName || 'default',
|
||||
size: status.pending,
|
||||
processing: status.active,
|
||||
failed: status.failed,
|
||||
retrying: status.retrying,
|
||||
averageProcessingTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
queues,
|
||||
totalItems: queues.reduce((sum, q) => sum + q.size + q.processing + q.failed + q.retrying, 0),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Health Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'getHealthStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
const health = await this.checkHealthStatus();
|
||||
return {
|
||||
health: {
|
||||
healthy: health.healthy,
|
||||
uptime: process.uptime(),
|
||||
services: health.services.reduce((acc, service) => {
|
||||
acc[service.name] = {
|
||||
status: service.status,
|
||||
message: service.message,
|
||||
lastCheck: Date.now(),
|
||||
};
|
||||
return acc;
|
||||
}, {} as any),
|
||||
version: '2.12.0', // TODO: Get from package.json
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Combined Metrics Handler - More efficient for frontend polling
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||
'getCombinedMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const sections = dataArg.sections || {
|
||||
server: true,
|
||||
email: true,
|
||||
dns: true,
|
||||
security: true,
|
||||
network: true,
|
||||
};
|
||||
|
||||
const metrics: any = {};
|
||||
|
||||
// Run all metrics collection in parallel
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (sections.server) {
|
||||
promises.push(
|
||||
this.collectServerStats().then(stats => {
|
||||
metrics.server = {
|
||||
uptime: stats.uptime,
|
||||
startTime: Date.now() - (stats.uptime * 1000),
|
||||
memoryUsage: stats.memoryUsage,
|
||||
cpuUsage: stats.cpuUsage,
|
||||
activeConnections: stats.activeConnections,
|
||||
totalConnections: stats.totalConnections,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.email) {
|
||||
promises.push(
|
||||
this.collectEmailStats().then(stats => {
|
||||
metrics.email = {
|
||||
sent: stats.sentToday,
|
||||
received: stats.receivedToday,
|
||||
bounced: Math.floor(stats.sentToday * stats.bounceRate / 100),
|
||||
queued: stats.queueSize,
|
||||
failed: 0,
|
||||
averageDeliveryTime: 0,
|
||||
deliveryRate: stats.deliveryRate,
|
||||
bounceRate: stats.bounceRate,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.dns) {
|
||||
promises.push(
|
||||
this.collectDnsStats().then(stats => {
|
||||
metrics.dns = {
|
||||
totalQueries: stats.totalQueries,
|
||||
cacheHits: stats.cacheHits,
|
||||
cacheMisses: stats.cacheMisses,
|
||||
cacheHitRate: stats.cacheHitRate,
|
||||
activeDomains: stats.topDomains.length,
|
||||
averageResponseTime: 0,
|
||||
queryTypes: stats.queryTypes,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
|
||||
metrics.security = {
|
||||
blockedIPs: stats.blockedIPs,
|
||||
reputationScores: {},
|
||||
spamDetected: stats.spamDetected,
|
||||
malwareDetected: stats.malwareDetected,
|
||||
phishingDetected: stats.phishingDetected,
|
||||
authenticationFailures: stats.authFailures,
|
||||
suspiciousActivities: stats.totalThreatsBlocked,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats().then(stats => {
|
||||
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
|
||||
stats.connectionsByIP.forEach((count, ip) => {
|
||||
connectionDetails.push({
|
||||
remoteAddress: ip,
|
||||
protocol: 'https' as any,
|
||||
state: 'established' as any,
|
||||
startTime: Date.now(),
|
||||
bytesIn: 0,
|
||||
bytesOut: 0,
|
||||
});
|
||||
});
|
||||
|
||||
metrics.network = {
|
||||
totalBandwidth: {
|
||||
in: stats.throughputRate.bytesInPerSecond,
|
||||
out: stats.throughputRate.bytesOutPerSecond,
|
||||
},
|
||||
activeConnections: stats.connectionsByIP.size,
|
||||
connectionDetails: connectionDetails.slice(0, 50), // Limit to 50 connections
|
||||
topEndpoints: stats.topIPs.map(ip => ({
|
||||
endpoint: ip.ip,
|
||||
requests: ip.count,
|
||||
bandwidth: {
|
||||
in: 0,
|
||||
out: 0,
|
||||
},
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async collectServerStats(): Promise<{
|
||||
uptime: number;
|
||||
cpuUsage: {
|
||||
user: number;
|
||||
system: number;
|
||||
};
|
||||
memoryUsage: interfaces.data.IServerStats['memoryUsage'];
|
||||
requestsPerSecond: number;
|
||||
activeConnections: number;
|
||||
totalConnections: number;
|
||||
history: Array<{
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}>;
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const serverStats = await this.opsServerRef.dcRouterRef.metricsManager.getServerStats();
|
||||
return {
|
||||
uptime: serverStats.uptime,
|
||||
cpuUsage: serverStats.cpuUsage,
|
||||
memoryUsage: serverStats.memoryUsage,
|
||||
requestsPerSecond: serverStats.requestsPerSecond,
|
||||
activeConnections: serverStats.activeConnections,
|
||||
totalConnections: serverStats.totalConnections,
|
||||
history: [], // TODO: Implement history tracking
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to basic stats if MetricsManager not available
|
||||
const uptime = process.uptime();
|
||||
const memUsage = process.memoryUsage();
|
||||
const cpuUsage = plugins.os.loadavg()[0] * 100 / plugins.os.cpus().length;
|
||||
|
||||
return {
|
||||
uptime,
|
||||
cpuUsage: {
|
||||
user: cpuUsage * 0.7,
|
||||
system: cpuUsage * 0.3,
|
||||
},
|
||||
memoryUsage: {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
external: memUsage.external,
|
||||
rss: memUsage.rss,
|
||||
},
|
||||
requestsPerSecond: 0,
|
||||
activeConnections: 0,
|
||||
totalConnections: 0,
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async collectEmailStats(): Promise<{
|
||||
sentToday: number;
|
||||
receivedToday: number;
|
||||
bounceRate: number;
|
||||
deliveryRate: number;
|
||||
queueSize: number;
|
||||
domainBreakdown?: { [domain: string]: interfaces.data.IEmailStats };
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const emailStats = await this.opsServerRef.dcRouterRef.metricsManager.getEmailStats();
|
||||
return {
|
||||
sentToday: emailStats.sentToday,
|
||||
receivedToday: emailStats.receivedToday,
|
||||
bounceRate: emailStats.bounceRate,
|
||||
deliveryRate: emailStats.deliveryRate,
|
||||
queueSize: emailStats.queueSize,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
sentToday: 0,
|
||||
receivedToday: 0,
|
||||
bounceRate: 0,
|
||||
deliveryRate: 100,
|
||||
queueSize: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async collectDnsStats(): Promise<{
|
||||
queriesPerSecond: number;
|
||||
totalQueries: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
cacheHitRate: number;
|
||||
topDomains: Array<{
|
||||
domain: string;
|
||||
count: number;
|
||||
}>;
|
||||
queryTypes: { [key: string]: number };
|
||||
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const dnsStats = await this.opsServerRef.dcRouterRef.metricsManager.getDnsStats();
|
||||
return {
|
||||
queriesPerSecond: dnsStats.queriesPerSecond,
|
||||
totalQueries: dnsStats.totalQueries,
|
||||
cacheHits: dnsStats.cacheHits,
|
||||
cacheMisses: dnsStats.cacheMisses,
|
||||
cacheHitRate: dnsStats.cacheHitRate,
|
||||
topDomains: dnsStats.topDomains,
|
||||
queryTypes: dnsStats.queryTypes,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
queriesPerSecond: 0,
|
||||
totalQueries: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
cacheHitRate: 0,
|
||||
topDomains: [],
|
||||
queryTypes: {},
|
||||
};
|
||||
}
|
||||
|
||||
private async getQueueStatus(): Promise<{
|
||||
pending: number;
|
||||
active: number;
|
||||
failed: number;
|
||||
retrying: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
nextRetry?: number;
|
||||
}>;
|
||||
}> {
|
||||
// TODO: Implement actual queue status collection
|
||||
return {
|
||||
pending: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async checkHealthStatus(): Promise<{
|
||||
healthy: boolean;
|
||||
services: Array<{
|
||||
name: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
message?: string;
|
||||
}>;
|
||||
checks: Array<{
|
||||
name: string;
|
||||
passed: boolean;
|
||||
message?: string;
|
||||
}>;
|
||||
}> {
|
||||
const services: Array<{
|
||||
name: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
message?: string;
|
||||
}> = [];
|
||||
|
||||
// Check HTTP Proxy
|
||||
if (this.opsServerRef.dcRouterRef.smartProxy) {
|
||||
services.push({
|
||||
name: 'HTTP/HTTPS Proxy',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check Email Server
|
||||
if (this.opsServerRef.dcRouterRef.emailServer) {
|
||||
services.push({
|
||||
name: 'Email Server',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check DNS Server
|
||||
if (this.opsServerRef.dcRouterRef.dnsServer) {
|
||||
services.push({
|
||||
name: 'DNS Server',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check OpsServer
|
||||
services.push({
|
||||
name: 'OpsServer',
|
||||
status: 'healthy',
|
||||
});
|
||||
|
||||
const healthy = services.every(s => s.status === 'healthy');
|
||||
|
||||
return {
|
||||
healthy,
|
||||
services,
|
||||
checks: [
|
||||
{
|
||||
name: 'Memory Usage',
|
||||
passed: process.memoryUsage().heapUsed < (plugins.os.totalmem() * 0.9),
|
||||
message: 'Memory usage within limits',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
56
ts/opsserver/helpers/guards.ts
Normal file
56
ts/opsserver/helpers/guards.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { AdminHandler } from '../handlers/admin.handler.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Helper function to use identity guards in handlers
|
||||
*
|
||||
* @example
|
||||
* // In a handler:
|
||||
* await passGuards(toolsArg, this.opsServerRef.adminHandler.validIdentityGuard, dataArg);
|
||||
*/
|
||||
export async function passGuards<T extends { identity?: any }>(
|
||||
toolsArg: any,
|
||||
guard: plugins.smartguard.Guard<T>,
|
||||
dataArg: T
|
||||
): Promise<void> {
|
||||
const result = await guard.exec(dataArg);
|
||||
if (!result) {
|
||||
const failedHint = await guard.getFailedHint(dataArg);
|
||||
throw new plugins.typedrequest.TypedResponseError(failedHint || 'Guard check failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check admin identity in handlers
|
||||
*/
|
||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
|
||||
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check valid identity in handlers
|
||||
*/
|
||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
|
||||
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
}
|
||||
1
ts/opsserver/index.ts
Normal file
1
ts/opsserver/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.opsserver.js';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user