Compare commits
35 Commits
Author | SHA1 | Date | |
---|---|---|---|
a29cff2fc5 | |||
d161fe4f19 | |||
df9a8ad14e | |||
8ddad6e652 | |||
3d36d3d1c5 | |||
329320cd40 | |||
63ecf60543 | |||
87917f68fb | |||
018b499010 | |||
a4d79c2d01 | |||
90d3e75963 | |||
4887ec9d93 | |||
983e6cb623 | |||
e9b2ec0f59 | |||
c084de9c78 | |||
2b207833ce | |||
4dc095e662 | |||
c1311f493f | |||
97cbe6e398 | |||
0bb9c5e1e5 | |||
cf90560243 | |||
8def86494a | |||
db46e01f6e | |||
7baf747972 | |||
4a17a1073e | |||
8997ded81d | |||
f177d8e9ab | |||
808a9cc856 | |||
be1c8d1164 | |||
2ecb2f3aa0 | |||
01dcdebda5 | |||
2adcc249de | |||
543e696bfc | |||
796e0204ca | |||
f5a36ab53a |
97
changelog.md
Normal file
97
changelog.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
Applied core fixes across several versions on this day.
|
||||||
|
|
||||||
|
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
|
||||||
|
|
||||||
|
## 2024-04-01 - 1.0.7 - core
|
||||||
|
Applied a core fix.
|
||||||
|
|
||||||
|
- Fixed core functionality for version 1.0.7
|
||||||
|
|
||||||
|
## 2024-03-19 - 1.0.6 - core
|
||||||
|
Applied a core fix.
|
||||||
|
|
||||||
|
- Fixed core functionality for version 1.0.6
|
||||||
|
|
||||||
|
## 2024-02-16 - 1.0.5 to 1.0.2 - core
|
||||||
|
Applied multiple core fixes in a contiguous range of versions.
|
||||||
|
|
||||||
|
- Fixed core functionality for versions 1.0.5, 1.0.4, 1.0.3, and 1.0.2
|
||||||
|
|
||||||
|
## 2024-02-15 - 1.0.1 - core
|
||||||
|
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.
|
@ -5,10 +5,32 @@
|
|||||||
"githost": "gitlab.com",
|
"githost": "gitlab.com",
|
||||||
"gitscope": "serve.zone",
|
"gitscope": "serve.zone",
|
||||||
"gitrepo": "platformservice",
|
"gitrepo": "platformservice",
|
||||||
"description": "contains the platformservice container with mail, sms, letter, ai services.",
|
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
||||||
"npmPackagename": "@serve.zone/platformservice",
|
"npmPackagename": "@serve.zone/platformservice",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "serve.zone"
|
"projectDomain": "serve.zone",
|
||||||
|
"keywords": [
|
||||||
|
"mail service",
|
||||||
|
"SMS",
|
||||||
|
"letter delivery",
|
||||||
|
"AI services",
|
||||||
|
"SMTP server",
|
||||||
|
"mail parsing",
|
||||||
|
"DKIM",
|
||||||
|
"platform service",
|
||||||
|
"mailgun integration",
|
||||||
|
"letterXpress",
|
||||||
|
"OpenAI",
|
||||||
|
"Anthropic AI",
|
||||||
|
"DKIM signing",
|
||||||
|
"mail forwarding",
|
||||||
|
"SMTP TLS",
|
||||||
|
"domain management",
|
||||||
|
"email templating",
|
||||||
|
"rule management",
|
||||||
|
"SMTP STARTTLS",
|
||||||
|
"DNS management"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npmci": {
|
"npmci": {
|
||||||
|
66
package.json
66
package.json
@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/platformservice",
|
"name": "@serve.zone/platformservice",
|
||||||
"version": "1.0.2",
|
"private": true,
|
||||||
"description": "contains the platformservice container with mail, sms, letter, ai services.",
|
"version": "2.3.0",
|
||||||
|
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -9,24 +10,71 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/)",
|
"test": "(tstest test/)",
|
||||||
"start": "(node --max_old_space_size=100 ./cli.js)",
|
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)"
|
"build": "(tsbuild tsfolders --allowimplicitany)",
|
||||||
|
"localPublish": ""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.1.17",
|
"@git.zone/tsbuild": "^2.1.17",
|
||||||
"@git.zone/tsrun": "^1.2.8",
|
"@git.zone/tsrun": "^1.2.8",
|
||||||
"@git.zone/tstest": "^1.0.28",
|
"@git.zone/tstest": "^1.0.88",
|
||||||
"@git.zone/tswatch": "^2.0.1",
|
"@git.zone/tswatch": "^2.0.1",
|
||||||
"@push.rocks/tapbundle": "^5.0.3"
|
"@push.rocks/tapbundle": "^5.0.22"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.0.4",
|
"@api.global/typedrequest": "^3.0.19",
|
||||||
"@api.global/typedserver": "^3.0.20",
|
"@api.global/typedserver": "^3.0.27",
|
||||||
|
"@api.global/typedsocket": "^3.0.0",
|
||||||
|
"@apiclient.xyz/cloudflare": "^6.0.3",
|
||||||
|
"@apiclient.xyz/letterxpress": "^1.0.20",
|
||||||
"@push.rocks/projectinfo": "^5.0.1",
|
"@push.rocks/projectinfo": "^5.0.1",
|
||||||
"@push.rocks/qenv": "^6.0.5",
|
"@push.rocks/qenv": "^6.0.5",
|
||||||
"@push.rocks/smartdata": "^5.0.7",
|
"@push.rocks/smartdata": "^5.0.7",
|
||||||
|
"@push.rocks/smartfile": "^11.0.4",
|
||||||
|
"@push.rocks/smartlog": "^3.0.3",
|
||||||
|
"@push.rocks/smartmail": "^1.0.24",
|
||||||
"@push.rocks/smartpath": "^5.0.5",
|
"@push.rocks/smartpath": "^5.0.5",
|
||||||
"@push.rocks/smartstate": "^2.0.0"
|
"@push.rocks/smartpromise": "^4.0.3",
|
||||||
|
"@push.rocks/smartproxy": "^4.1.0",
|
||||||
|
"@push.rocks/smartrequest": "^2.0.21",
|
||||||
|
"@push.rocks/smartrule": "^2.0.1",
|
||||||
|
"@push.rocks/smartrx": "^3.0.7",
|
||||||
|
"@push.rocks/smartstate": "^2.0.0",
|
||||||
|
"@serve.zone/interfaces": "^4.12.1",
|
||||||
|
"@tsclass/tsclass": "^5.0.0",
|
||||||
|
"@types/mailparser": "^3.4.5",
|
||||||
|
"mailauth": "^4.6.5",
|
||||||
|
"mailparser": "^3.6.9",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mail service",
|
||||||
|
"SMS",
|
||||||
|
"letter delivery",
|
||||||
|
"AI services",
|
||||||
|
"SMTP server",
|
||||||
|
"mail parsing",
|
||||||
|
"DKIM",
|
||||||
|
"platform service",
|
||||||
|
"mailgun integration",
|
||||||
|
"letterXpress",
|
||||||
|
"OpenAI",
|
||||||
|
"Anthropic AI",
|
||||||
|
"DKIM signing",
|
||||||
|
"mail forwarding",
|
||||||
|
"SMTP TLS",
|
||||||
|
"domain management",
|
||||||
|
"email templating",
|
||||||
|
"rule management",
|
||||||
|
"SMTP STARTTLS",
|
||||||
|
"DNS management"
|
||||||
|
],
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"mongodb-memory-server",
|
||||||
|
"puppeteer"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
13567
pnpm-lock.yaml
generated
13567
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
0
readme.hints.md
Normal file
0
readme.hints.md
Normal file
215
readme.md
215
readme.md
@ -1,31 +1,200 @@
|
|||||||
# @serve.zone/platformservice
|
# @serve.zone/platformservice
|
||||||
|
|
||||||
contains the platformservice container with mail, sms, letter, ai services.
|
contains the platformservice container with mail, sms, letter, ai services.
|
||||||
|
|
||||||
## Availabililty and Links
|
## Install
|
||||||
* [npmjs.org (npm package)](https://www.npmjs.com/package/@serve.zone/platformservice)
|
|
||||||
* [gitlab.com (source)](https://gitlab.com/serve.zone/platformservice)
|
|
||||||
* [github.com (source mirror)](https://github.com/serve.zone/platformservice)
|
|
||||||
* [docs (typedoc)](https://serve.zone.gitlab.io/platformservice/)
|
|
||||||
|
|
||||||
## Status for master
|
To install `@serve.zone/platformservice`, run the following command:
|
||||||
|
|
||||||
Status Category | Status Badge
|
```sh
|
||||||
-- | --
|
npm install @serve.zone/platformservice --save
|
||||||
GitLab Pipelines | [](https://lossless.cloud)
|
```
|
||||||
GitLab Pipline Test Coverage | [](https://lossless.cloud)
|
|
||||||
npm | [](https://lossless.cloud)
|
Make sure you have Node.js and npm installed on your system to use this package.
|
||||||
Snyk | [](https://lossless.cloud)
|
|
||||||
TypeScript Support | [](https://lossless.cloud)
|
|
||||||
node Support | [](https://nodejs.org/dist/latest-v10.x/docs/api/)
|
|
||||||
Code Style | [](https://lossless.cloud)
|
|
||||||
PackagePhobia (total standalone install weight) | [](https://lossless.cloud)
|
|
||||||
PackagePhobia (package size on registry) | [](https://lossless.cloud)
|
|
||||||
BundlePhobia (total size when bundled) | [](https://lossless.cloud)
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
Use TypeScript for best in class intellisense.
|
|
||||||
For further information read the linked docs at the top of this readme.
|
|
||||||
|
|
||||||
## Legal
|
This document provides extensive usage scenarios for the `@serve.zone/platformservice`, a comprehensive ESM module written in TypeScript offering a wide range of services such as mail, SMS, letter, and artificial intelligence (AI) functionalities. This service is an exemplar of a modular design, allowing users to leverage various communication methods and AI services efficiently. Key features provided by this platform include sending and receiving emails, managing SMS services, letter dispatching, and utilizing AI for diverse purposes.
|
||||||
> MIT licensed | **©** [Task Venture Capital GmbH](https://task.vc)
|
|
||||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
### Prerequisites
|
||||||
|
|
||||||
|
Before diving into the examples, ensure you have the platform service installed and configured correctly. The package leverages environment variables for configuration, so you must set up the necessary variables, including service endpoints, authentication tokens, and database connections.
|
||||||
|
|
||||||
|
### Initialization
|
||||||
|
|
||||||
|
First, initialize the platform service, ensuring all dependencies are correctly loaded and configured:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SzPlatformService } from '@serve.zone/platformservice';
|
||||||
|
|
||||||
|
async function initService() {
|
||||||
|
const platformService = new SzPlatformService();
|
||||||
|
await platformService.start();
|
||||||
|
console.log('Platform service initialized successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
initService();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending Emails
|
||||||
|
|
||||||
|
One of the primary services offered is email management. Here's how to send an email using the platform service:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { EmailService, IEmailOptions } from '@serve.zone/platformservice';
|
||||||
|
|
||||||
|
async function sendEmail() {
|
||||||
|
const emailOptions: IEmailOptions = {
|
||||||
|
from: 'no-reply@example.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Test Email',
|
||||||
|
body: '<h1>This is a test email</h1>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailService = new EmailService('MAILGUN_API_KEY'); // Replace with your real API key
|
||||||
|
await emailService.sendEmail(emailOptions);
|
||||||
|
|
||||||
|
console.log('Email sent successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEmail();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing SMS
|
||||||
|
|
||||||
|
Similar to email, the platform also facilitates SMS sending:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SmsService, ISmsConstructorOptions } from '@serve.zone/platformservice';
|
||||||
|
|
||||||
|
async function sendSms() {
|
||||||
|
const smsOptions: ISmsConstructorOptions = {
|
||||||
|
apiGatewayApiToken: 'SMS_API_TOKEN', // Replace with your real token
|
||||||
|
};
|
||||||
|
|
||||||
|
const smsService = new SmsService(smsOptions);
|
||||||
|
await smsService.sendSms(1234567890, 'SENDER_NAME', 'This is a test SMS.');
|
||||||
|
|
||||||
|
console.log('SMS sent successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSms();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dispatching Letters
|
||||||
|
|
||||||
|
For physical mail correspondence, the platform provides a letter service:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { LetterService, ILetterConstructorOptions } from '@serve.zone/platformservice';
|
||||||
|
|
||||||
|
async function sendLetter() {
|
||||||
|
const letterOptions: ILetterConstructorOptions = {
|
||||||
|
letterxpressUser: 'USER',
|
||||||
|
letterxpressToken: 'TOKEN',
|
||||||
|
};
|
||||||
|
|
||||||
|
const letterService = new LetterService(letterOptions);
|
||||||
|
await letterService.sendLetter('This is a test letter body.', {address: 'Recipient Address', name: 'Recipient Name'});
|
||||||
|
|
||||||
|
console.log('Letter dispatched successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
sendLetter();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail Transfer Agent (MTA)
|
||||||
|
|
||||||
|
The platform includes a robust Mail Transfer Agent (MTA) for enterprise-grade email handling with complete control over the email delivery process:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
API[API Clients] --> ApiManager
|
||||||
|
SMTP[External SMTP Servers] <--> SMTPServer
|
||||||
|
|
||||||
|
subgraph "MTA Service"
|
||||||
|
MtaService[MTA Service] --> SMTPServer[SMTP Server]
|
||||||
|
MtaService --> EmailSendJob[Email Send Job]
|
||||||
|
MtaService --> DnsManager[DNS Manager]
|
||||||
|
MtaService --> DkimCreator[DKIM Creator]
|
||||||
|
ApiManager[API Manager] --> MtaService
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "External Services"
|
||||||
|
DnsManager <--> DNS[DNS Servers]
|
||||||
|
EmailSendJob <--> MXServers[MX Servers]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The MTA service provides:
|
||||||
|
- Complete SMTP server for receiving emails
|
||||||
|
- DKIM signing and verification
|
||||||
|
- SPF and DMARC support
|
||||||
|
- DNS record management
|
||||||
|
- Retry logic with queue processing
|
||||||
|
- TLS encryption
|
||||||
|
|
||||||
|
Here's how to use the MTA service:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MtaService, Email } from '@serve.zone/platformservice';
|
||||||
|
|
||||||
|
async function useMtaService() {
|
||||||
|
// Initialize MTA service
|
||||||
|
const mtaService = new MtaService(platformService);
|
||||||
|
await mtaService.start();
|
||||||
|
|
||||||
|
// Send an email
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@yourdomain.com',
|
||||||
|
to: 'recipient@example.com',
|
||||||
|
subject: 'Hello World',
|
||||||
|
text: 'This is a test email',
|
||||||
|
html: '<p>This is a <b>test</b> email</p>',
|
||||||
|
attachments: [] // Optional attachments
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailId = await mtaService.send(email);
|
||||||
|
console.log(`Email queued with ID: ${emailId}`);
|
||||||
|
|
||||||
|
// Check email status
|
||||||
|
const status = mtaService.getEmailStatus(emailId);
|
||||||
|
console.log(`Email status: ${status.status}`);
|
||||||
|
|
||||||
|
// Set up API for external access
|
||||||
|
const apiManager = new ApiManager(mtaService);
|
||||||
|
await apiManager.start(3000);
|
||||||
|
console.log('MTA API running on port 3000');
|
||||||
|
}
|
||||||
|
|
||||||
|
useMtaService();
|
||||||
|
```
|
||||||
|
|
||||||
|
The MTA provides key advantages for applications requiring:
|
||||||
|
- High-volume email sending
|
||||||
|
- Compliance with email authentication standards
|
||||||
|
- Detailed delivery tracking
|
||||||
|
- Custom email handling logic
|
||||||
|
- Multi-domain email management
|
||||||
|
- Complete control over email infrastructure
|
||||||
|
|
||||||
|
### Leveraging AI Services
|
||||||
|
|
||||||
|
The platform also integrates AI functionalities, allowing for innovative use cases like generating content, analyzing text, or automating responses:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AiService } from '@serve.zone/platformservice';
|
||||||
|
|
||||||
|
async function useAiService() {
|
||||||
|
const aiService = new AiService('OPENAI_API_KEY'); // Replace with your real API key
|
||||||
|
const response = await aiService.generateText('Prompt for the AI service.');
|
||||||
|
|
||||||
|
console.log(`AI response: ${response}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
useAiService();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conclusion
|
||||||
|
|
||||||
|
The `@serve.zone/platformservice` offers a robust set of features for modern application requirements, including but not limited to communication and AI services. By following the examples above, developers can integrate these services into their applications, harnessing the power of email, SMS, letters, MTA capabilities, and artificial intelligence seamlessly.
|
5
test/test.ts
Normal file
5
test/test.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||||||
|
|
||||||
|
tap.test('should create a platform service', async () => {});
|
||||||
|
|
||||||
|
tap.start();
|
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* autocreated commitinfo by @pushrocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/platformservice',
|
name: '@serve.zone/platformservice',
|
||||||
version: '1.0.2',
|
version: '2.3.0',
|
||||||
description: 'contains the platformservice container with mail, sms, letter, ai services.'
|
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
||||||
}
|
}
|
||||||
|
3
ts/aibridge/classes.aibridge.ts
Normal file
3
ts/aibridge/classes.aibridge.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export class AIBridge {
|
||||||
|
|
||||||
|
}
|
@ -1,21 +0,0 @@
|
|||||||
import * as plugins from './platformservice.plugins.js';
|
|
||||||
import * as paths from './platformservice.paths.js';
|
|
||||||
import { PlatformServiceDb } from './classes.platformservicedb.js'
|
|
||||||
|
|
||||||
export class SzPlatformService {
|
|
||||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
|
||||||
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
|
||||||
public platformserviceDb: PlatformServiceDb;
|
|
||||||
|
|
||||||
public typedserver: plugins.typedserver.TypedServer;
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
this.platformserviceDb = new PlatformServiceDb(this);
|
|
||||||
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
|
||||||
this.typedserver = new plugins.typedserver.TypedServer({
|
|
||||||
cors: true,
|
|
||||||
});
|
|
||||||
await this.typedserver.start();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
import * as plugins from './platformservice.plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import { SzPlatformService } from './classes.platformservice.js';
|
import { SzPlatformService } from './platformservice.js';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
0
ts/dcrouter/classes.dcrouter.ts
Normal file
0
ts/dcrouter/classes.dcrouter.ts
Normal file
87
ts/email/classes.apimanager.ts
Normal file
87
ts/email/classes.apimanager.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
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.IRequest_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<{ emailId: string }>(
|
||||||
|
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<void>(
|
||||||
|
new plugins.typedrequest.TypedHandler('getEmailStats', async () => {
|
||||||
|
return this.emailRef.getStats();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
169
ts/email/classes.connector.mta.ts
Normal file
169
ts/email/classes.connector.mta.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
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<>,
|
||||||
|
toAddresses: string | string[],
|
||||||
|
options: any = {}
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Process recipients
|
||||||
|
const toArray = Array.isArray(toAddresses)
|
||||||
|
? toAddresses
|
||||||
|
: toAddresses.split(',').map(addr => addr.trim());
|
||||||
|
|
||||||
|
// Map SmartMail attachments to MTA attachments
|
||||||
|
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
|
||||||
|
return {
|
||||||
|
filename: attachment.parsedPath.base,
|
||||||
|
content: Buffer.from(attachment.contentBuffer),
|
||||||
|
contentType: (attachment as any)?.getContentType?.() || 'application/octet-stream' // TODO: revisit after smartfile has been updated
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create MTA Email
|
||||||
|
const mtaEmail = new MtaEmail({
|
||||||
|
from: smartmail.options.from,
|
||||||
|
to: toArray,
|
||||||
|
subject: smartmail.getSubject(),
|
||||||
|
text: smartmail.getBody(false), // Plain text version
|
||||||
|
html: smartmail.getBody(true), // HTML version
|
||||||
|
attachments
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send using MTA
|
||||||
|
const emailId = await this.mtaService.send(mtaEmail);
|
||||||
|
|
||||||
|
logger.log('info', `Email sent via MTA to ${toAddresses}`, {
|
||||||
|
eventType: 'sentEmail',
|
||||||
|
provider: 'mta',
|
||||||
|
emailId,
|
||||||
|
to: toAddresses
|
||||||
|
});
|
||||||
|
|
||||||
|
return emailId;
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
|
||||||
|
eventType: 'emailError',
|
||||||
|
provider: 'mta',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
public async receiveEmail(emailData: string): Promise<plugins.smartmail.Smartmail<>> {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Create a Smartmail from the parsed email
|
||||||
|
const smartmail = new plugins.smartmail.Smartmail({
|
||||||
|
from: parsedEmail.from?.text || '',
|
||||||
|
subject: parsedEmail.subject || '',
|
||||||
|
body: parsedEmail.html || parsedEmail.text || '',
|
||||||
|
creationObjectRef: {
|
||||||
|
From: parsedEmail.from?.text || '',
|
||||||
|
To: parsedEmail.to?.text || '',
|
||||||
|
Subject: parsedEmail.subject || ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add attachments if present
|
||||||
|
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
|
||||||
|
for (const attachment of parsedEmail.attachments) {
|
||||||
|
smartmail.addAttachment(
|
||||||
|
await plugins.smartfile.SmartFile.fromBuffer(
|
||||||
|
attachment.filename || 'attachment',
|
||||||
|
attachment.content
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
131
ts/email/classes.emailservice.ts
Normal file
131
ts/email/classes.emailservice.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
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 { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
// 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 || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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<>,
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
177
ts/email/classes.rulemanager.ts
Normal file
177
ts/email/classes.rulemanager.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
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,4 +1,4 @@
|
|||||||
import * as plugins from './email.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export class TemplateManager {
|
export class TemplateManager {
|
||||||
public smartmailDefault = new plugins.smartmail.Smartmail({
|
public smartmailDefault = new plugins.smartmail.Smartmail({
|
@ -1,52 +0,0 @@
|
|||||||
import * as plugins from './email.plugins.js';
|
|
||||||
import { Email } from './email.classes.email.js';
|
|
||||||
import { request } from 'http';
|
|
||||||
import { logger } from './email.logging.js';
|
|
||||||
|
|
||||||
export class ApiManager {
|
|
||||||
public emailRef: Email;
|
|
||||||
|
|
||||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(emailRefArg: Email) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
this.emailRef.mainTypedRouter.addTypedRouter(this.typedRouter);
|
|
||||||
this.typedRouter.addTypedHandler<plugins.lointEmail.IRequestSendEmail>(
|
|
||||||
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'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.emailRef.mailgunConnector.sendEmail(mailToSend, requestData.to, {});
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`send an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
|
|
||||||
{
|
|
||||||
eventType: 'sentEmail',
|
|
||||||
email: {
|
|
||||||
to: requestData.to,
|
|
||||||
subject: mailToSend.getSubject(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
responseId: 'abc', // TODO: generate proper response id
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
import * as plugins from './email.plugins.js';
|
|
||||||
import { Email } from './email.classes.email.js';
|
|
||||||
|
|
||||||
export class MailgunConnector {
|
|
||||||
public emailRef: Email;
|
|
||||||
public mailgunAccount: plugins.mailgun.MailgunAccount;
|
|
||||||
|
|
||||||
constructor(emailRefArg: Email) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
this.mailgunAccount = new plugins.mailgun.MailgunAccount({
|
|
||||||
apiToken: this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_API_TOKEN'),
|
|
||||||
region: 'eu',
|
|
||||||
});
|
|
||||||
this.mailgunAccount.addSmtpCredentials(
|
|
||||||
this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_SMTP_CREDENTIALS')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async sendEmail(
|
|
||||||
smartMailArg: plugins.smartmail.Smartmail<any>,
|
|
||||||
toArg: string,
|
|
||||||
dataArg: any = {}
|
|
||||||
) {
|
|
||||||
this.mailgunAccount.sendSmartMail(smartMailArg, toArg, dataArg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async receiveEmail(messageUrl: string) {
|
|
||||||
return await this.mailgunAccount.retrieveSmartMailFromMessageUrl(messageUrl);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
import * as plugins from './email.plugins.js';
|
|
||||||
import * as paths from './email.paths.js';
|
|
||||||
import { MailgunConnector } from './email.classes.connector.mailgun.js';
|
|
||||||
import { RuleManager } from './email.classes.rulemanager.js';
|
|
||||||
import { ApiManager } from './email.classes.apimanager.js';
|
|
||||||
import { logger } from './email.logging.js';
|
|
||||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
|
||||||
|
|
||||||
export class Email {
|
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
|
|
||||||
// typedrouter
|
|
||||||
public mainTypedRouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
// connectors
|
|
||||||
public mailgunConnector: MailgunConnector;
|
|
||||||
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
|
||||||
|
|
||||||
// server
|
|
||||||
public apiManager = new ApiManager(this);
|
|
||||||
public ruleManager: RuleManager;
|
|
||||||
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService) {
|
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
|
||||||
this.mailgunConnector = new MailgunConnector(this);
|
|
||||||
this.ruleManager = new RuleManager(this);
|
|
||||||
this.platformServiceRef.typedserver.server.addRoute(
|
|
||||||
'/mailgun-notify',
|
|
||||||
new plugins.loleServiceserver.Handler('POST', async (req, res) => {
|
|
||||||
console.log('Got a mailgun email notification');
|
|
||||||
res.status(200);
|
|
||||||
res.end();
|
|
||||||
this.ruleManager.handleNotification(req.body);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
await this.ruleManager.init();
|
|
||||||
logger.log('success', `Started email service`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -1,137 +0,0 @@
|
|||||||
import * as plugins from './email.plugins.js';
|
|
||||||
import { Email } from './email.classes.email.js';
|
|
||||||
import { logger } from './email.logging.js';
|
|
||||||
|
|
||||||
export class RuleManager {
|
|
||||||
public emailRef: Email;
|
|
||||||
public smartruleInstance = new plugins.smartrule.SmartRule<
|
|
||||||
plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>
|
|
||||||
>();
|
|
||||||
|
|
||||||
constructor(emailRefArg: Email) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleNotification(notification: plugins.mailgun.IMailgunNotification) {
|
|
||||||
console.log(notification['message-url']);
|
|
||||||
|
|
||||||
// basic checks here
|
|
||||||
// none for now
|
|
||||||
|
|
||||||
const fetchedSmartmail = await this.emailRef.mailgunConnector.receiveEmail(
|
|
||||||
notification['message-url']
|
|
||||||
);
|
|
||||||
console.log('=======================');
|
|
||||||
console.log('Received a mail:');
|
|
||||||
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',
|
|
||||||
email: {
|
|
||||||
from: fetchedSmartmail.options.creationObjectRef.From,
|
|
||||||
to: fetchedSmartmail.options.creationObjectRef.To,
|
|
||||||
subject: fetchedSmartmail.options.creationObjectRef.Subject,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.smartruleInstance.makeDecision(fetchedSmartmail);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async init() {
|
|
||||||
// lets forward stuff
|
|
||||||
await this.createForwards();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* creates the default forwards
|
|
||||||
*/
|
|
||||||
public async createForwards() {
|
|
||||||
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [
|
|
||||||
{
|
|
||||||
originalToAddress: ['bot@mail.nevermind.group'],
|
|
||||||
forwardedToAddress: ['phil@metadata.company', 'dominik@metadata.company'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
originalToAddress: ['legal@mail.lossless.com'],
|
|
||||||
forwardedToAddress: ['phil@lossless.com'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
originalToAddress: ['christine.nyamwaro@mail.lossless.com', 'christine@nyamwaro.com'],
|
|
||||||
forwardedToAddress: ['phil@lossless.com'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
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<plugins.mailgun.IMailgunMessage>) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
await this.emailRef.mailgunConnector.sendEmail(forwardedSmartMail, toArg);
|
|
||||||
console.log(`forwarded mail to ${toArg}`);
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`email from ${
|
|
||||||
smartmailArg.options.creationObjectRef.From
|
|
||||||
} to phil@lossless.com 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,13 +0,0 @@
|
|||||||
import * as plugins from './email.plugins.js';
|
|
||||||
import * as paths from './email.paths.js';
|
|
||||||
|
|
||||||
const projectInfoNpm = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
|
|
||||||
|
|
||||||
export const logger = plugins.loleLog.createLoleLogger({
|
|
||||||
companyUnit: 'lossless.cloud',
|
|
||||||
containerName: 'email',
|
|
||||||
containerVersion: projectInfoNpm.version,
|
|
||||||
sentryAppName: 'email',
|
|
||||||
sentryDsn: 'https://7037e86f36134ced85ae56a57daa1e5e@o169278.ingest.sentry.io/5280282',
|
|
||||||
zone: 'servezone',
|
|
||||||
});
|
|
@ -1,6 +0,0 @@
|
|||||||
import * as plugins from './email.plugins.js';
|
|
||||||
|
|
||||||
export const packageDir = plugins.path.join(
|
|
||||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
|
||||||
'../'
|
|
||||||
);
|
|
@ -1,43 +0,0 @@
|
|||||||
// native scope
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export { path };
|
|
||||||
|
|
||||||
// @losslessone_private scope
|
|
||||||
import * as loleLog from '@losslessone_private/lole-log';
|
|
||||||
import * as loleServiceserver from '@losslessone_private/lole-serviceserver';
|
|
||||||
|
|
||||||
import * as lointEmail from '@losslessone_private/loint-email';
|
|
||||||
|
|
||||||
export { loleLog, loleServiceserver, lointEmail };
|
|
||||||
|
|
||||||
// @apiglobal scope
|
|
||||||
import * as typedrequest from '@apiglobal/typedrequest';
|
|
||||||
|
|
||||||
export { typedrequest };
|
|
||||||
|
|
||||||
// @mojoio scope
|
|
||||||
import * as mailgun from '@mojoio/mailgun';
|
|
||||||
|
|
||||||
export { mailgun };
|
|
||||||
|
|
||||||
// @pushrocks scope
|
|
||||||
import * as projectinfo from '@pushrocks/projectinfo';
|
|
||||||
import * as qenv from '@pushrocks/qenv';
|
|
||||||
import * as smartfile from '@pushrocks/smartfile';
|
|
||||||
import * as smartmail from '@pushrocks/smartmail';
|
|
||||||
import * as smartpath from '@pushrocks/smartpath';
|
|
||||||
import * as smartrequest from '@pushrocks/smartrequest';
|
|
||||||
import * as smartrule from '@pushrocks/smartrule';
|
|
||||||
import * as smartvalidator from '@pushrocks/smartvalidator';
|
|
||||||
|
|
||||||
export {
|
|
||||||
projectinfo,
|
|
||||||
qenv,
|
|
||||||
smartfile,
|
|
||||||
smartmail,
|
|
||||||
smartpath,
|
|
||||||
smartrequest,
|
|
||||||
smartrule,
|
|
||||||
smartvalidator,
|
|
||||||
};
|
|
@ -1,3 +1,3 @@
|
|||||||
import { Email } from './email.classes.email.js';
|
import { EmailService } from './email.classes.emailservice.js';
|
||||||
|
|
||||||
export { Email };
|
export { EmailService as Email };
|
@ -1,4 +1,4 @@
|
|||||||
export * from './00_commitinfo_data.js';
|
export * from './00_commitinfo_data.js';
|
||||||
import { SzPlatformService } from './classes.platformservice.js'
|
import { SzPlatformService } from './platformservice.js';
|
||||||
|
|
||||||
export const runCli = async () => {}
|
export const runCli = async () => {}
|
41
ts/letter/classes.letterservice.ts
Normal file
41
ts/letter/classes.letterservice.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { SzPlatformService } from '../platformservice.js';
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
export interface ILetterConstructorOptions {
|
||||||
|
letterxpressUser: string;
|
||||||
|
letterxpressToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LetterService {
|
||||||
|
public platformServiceRef: SzPlatformService;
|
||||||
|
public options: ILetterConstructorOptions;
|
||||||
|
public letterxpressAccount: plugins.letterxpress.LetterXpressAccount;
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ILetterConstructorOptions) {
|
||||||
|
this.platformServiceRef = platformServiceRefArg;
|
||||||
|
this.options = optionsArg;
|
||||||
|
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler<
|
||||||
|
plugins.servezoneInterfaces.platformservice.letter.IRequest_SendLetter
|
||||||
|
>(new plugins.typedrequest.TypedHandler('sendLetter', async dataArg => {
|
||||||
|
if(dataArg.needsCover) {
|
||||||
|
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
processId: '',
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start() {
|
||||||
|
this.letterxpressAccount = new plugins.letterxpress.LetterXpressAccount({
|
||||||
|
username: this.options.letterxpressUser,
|
||||||
|
apiKey: this.options.letterxpressToken,
|
||||||
|
});
|
||||||
|
await this.letterxpressAccount.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {}
|
||||||
|
}
|
1
ts/letter/index.ts
Normal file
1
ts/letter/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './classes.letterservice.js';
|
9
ts/logger.ts
Normal file
9
ts/logger.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
export const logger = new plugins.smartlog.Smartlog({
|
||||||
|
logContext: {
|
||||||
|
environment: 'production',
|
||||||
|
runtime: 'node',
|
||||||
|
zone: 'serve.zone',
|
||||||
|
}
|
||||||
|
});
|
956
ts/mta/classes.apimanager.ts
Normal file
956
ts/mta/classes.apimanager.ts
Normal file
@ -0,0 +1,956 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
120
ts/mta/classes.dkimcreator.ts
Normal file
120
ts/mta/classes.dkimcreator.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
35
ts/mta/classes.dkimverifier.ts
Normal file
35
ts/mta/classes.dkimverifier.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { MtaService } from './classes.mta.js';
|
||||||
|
|
||||||
|
class DKIMVerifier {
|
||||||
|
public mtaRef: MtaService;
|
||||||
|
|
||||||
|
constructor(mtaRefArg: MtaService) {
|
||||||
|
this.mtaRef = mtaRefArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(email: string): Promise<boolean> {
|
||||||
|
console.log('Trying to verify DKIM now...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verification = await plugins.mailauth.authenticate(email, {
|
||||||
|
/* resolver: (...args) => {
|
||||||
|
console.log(args);
|
||||||
|
} */
|
||||||
|
});
|
||||||
|
console.log(verification);
|
||||||
|
if (verification && verification.dkim.results[0].status.result === 'pass') {
|
||||||
|
console.log('DKIM Verification result: pass');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.error('DKIM Verification failed:', verification?.error || 'Unknown error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DKIM Verification failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DKIMVerifier };
|
559
ts/mta/classes.dnsmanager.ts
Normal file
559
ts/mta/classes.dnsmanager.ts
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as paths from '../paths.js';
|
||||||
|
import type { MtaService } from './mta.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;
|
||||||
|
}
|
||||||
|
}
|
219
ts/mta/classes.email.ts
Normal file
219
ts/mta/classes.email.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
constructor(options: IEmailOptions) {
|
||||||
|
// Validate and set the from address
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an email address using a regex pattern
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
// Basic but effective email regex
|
||||||
|
const emailRegex = /^[^\s@]+@([^\s@.,]+\.)+[^\s@.,]{2,}$/;
|
||||||
|
return emailRegex.test(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an RFC822 compliant email string
|
||||||
|
* @returns The email formatted as an RFC822 compliant string
|
||||||
|
*/
|
||||||
|
public toRFC822String(): string {
|
||||||
|
// 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: ${this.subject}\r\n`;
|
||||||
|
result += `Date: ${new Date().toUTCString()}\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`;
|
||||||
|
result += `\r\n${this.text}\r\n`;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
623
ts/mta/classes.emailsendjob.ts
Normal file
623
ts/mta/classes.emailsendjob.ts
Normal file
@ -0,0 +1,623 @@
|
|||||||
|
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}`);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
this.socket = plugins.net.connect(25, mxServer);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
}
|
69
ts/mta/classes.emailsignjob.ts
Normal file
69
ts/mta/classes.emailsignjob.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { MtaService } from './mta.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;
|
||||||
|
}
|
||||||
|
}
|
945
ts/mta/classes.mta.ts
Normal file
945
ts/mta/classes.mta.ts
Normal file
@ -0,0 +1,945 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as paths from '../paths.js';
|
||||||
|
|
||||||
|
import { Email } from './classes.email.js';
|
||||||
|
import { EmailSendJob, DeliveryStatus } from './classes.emailsendjob.js';
|
||||||
|
import { DKIMCreator } from './classes.dkimcreator.js';
|
||||||
|
import { DKIMVerifier } from './classes.dkimverifier.js';
|
||||||
|
import { SMTPServer, type ISmtpServerOptions } from './classes.smtpserver.js';
|
||||||
|
import { DNSManager } from './classes.dnsmanager.js';
|
||||||
|
import { ApiManager } from './classes.apimanager.js';
|
||||||
|
import type { SzPlatformService } from '../platformservice.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for the MTA service
|
||||||
|
*/
|
||||||
|
export interface IMtaConfig {
|
||||||
|
/** SMTP server options */
|
||||||
|
smtp?: {
|
||||||
|
/** Whether to enable the SMTP server */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Port to listen on (default: 25) */
|
||||||
|
port?: number;
|
||||||
|
/** SMTP server hostname */
|
||||||
|
hostname?: string;
|
||||||
|
/** Maximum allowed email size in bytes */
|
||||||
|
maxSize?: number;
|
||||||
|
};
|
||||||
|
/** SSL/TLS configuration */
|
||||||
|
tls?: {
|
||||||
|
/** Domain for certificate */
|
||||||
|
domain?: string;
|
||||||
|
/** Whether to auto-renew certificates */
|
||||||
|
autoRenew?: boolean;
|
||||||
|
/** Custom key/cert paths (if not using auto-provision) */
|
||||||
|
keyPath?: string;
|
||||||
|
certPath?: string;
|
||||||
|
};
|
||||||
|
/** Outbound email sending configuration */
|
||||||
|
outbound?: {
|
||||||
|
/** Maximum concurrent sending jobs */
|
||||||
|
concurrency?: number;
|
||||||
|
/** Retry configuration */
|
||||||
|
retries?: {
|
||||||
|
/** Maximum number of retries per message */
|
||||||
|
max?: number;
|
||||||
|
/** Initial delay between retries (milliseconds) */
|
||||||
|
delay?: number;
|
||||||
|
/** Whether to use exponential backoff for retries */
|
||||||
|
useBackoff?: boolean;
|
||||||
|
};
|
||||||
|
/** Rate limiting configuration */
|
||||||
|
rateLimit?: {
|
||||||
|
/** Maximum emails per period */
|
||||||
|
maxPerPeriod?: number;
|
||||||
|
/** Time period in milliseconds */
|
||||||
|
periodMs?: number;
|
||||||
|
/** Whether to apply per domain (vs globally) */
|
||||||
|
perDomain?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Security settings */
|
||||||
|
security?: {
|
||||||
|
/** Whether to use DKIM signing */
|
||||||
|
useDkim?: boolean;
|
||||||
|
/** Whether to verify inbound DKIM signatures */
|
||||||
|
verifyDkim?: boolean;
|
||||||
|
/** Whether to verify SPF on inbound */
|
||||||
|
verifySpf?: boolean;
|
||||||
|
/** Whether to use TLS for outbound when available */
|
||||||
|
useTls?: boolean;
|
||||||
|
/** Whether to require valid certificates */
|
||||||
|
requireValidCerts?: boolean;
|
||||||
|
};
|
||||||
|
/** Domains configuration */
|
||||||
|
domains?: {
|
||||||
|
/** List of domains that this MTA will handle as local */
|
||||||
|
local?: string[];
|
||||||
|
/** Whether to auto-create DNS records */
|
||||||
|
autoCreateDnsRecords?: boolean;
|
||||||
|
/** DKIM selector to use (default: "mta") */
|
||||||
|
dkimSelector?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email queue entry
|
||||||
|
*/
|
||||||
|
interface QueueEntry {
|
||||||
|
id: string;
|
||||||
|
email: Email;
|
||||||
|
addedAt: Date;
|
||||||
|
processing: boolean;
|
||||||
|
attempts: number;
|
||||||
|
lastAttempt?: Date;
|
||||||
|
nextAttempt?: Date;
|
||||||
|
error?: Error;
|
||||||
|
status: DeliveryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificate information
|
||||||
|
*/
|
||||||
|
interface Certificate {
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats for MTA monitoring
|
||||||
|
*/
|
||||||
|
interface MtaStats {
|
||||||
|
startTime: Date;
|
||||||
|
emailsReceived: number;
|
||||||
|
emailsSent: number;
|
||||||
|
emailsFailed: number;
|
||||||
|
activeConnections: number;
|
||||||
|
queueSize: number;
|
||||||
|
certificateInfo?: {
|
||||||
|
domain: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
daysUntilExpiry: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main MTA Service class that coordinates all email functionality
|
||||||
|
*/
|
||||||
|
export class MtaService {
|
||||||
|
/** Reference to the platform service */
|
||||||
|
public platformServiceRef: SzPlatformService;
|
||||||
|
|
||||||
|
/** SMTP server instance */
|
||||||
|
public server: SMTPServer;
|
||||||
|
|
||||||
|
/** DKIM creator for signing outgoing emails */
|
||||||
|
public dkimCreator: DKIMCreator;
|
||||||
|
|
||||||
|
/** DKIM verifier for validating incoming emails */
|
||||||
|
public dkimVerifier: DKIMVerifier;
|
||||||
|
|
||||||
|
/** DNS manager for handling DNS records */
|
||||||
|
public dnsManager: DNSManager;
|
||||||
|
|
||||||
|
/** API manager for external integrations */
|
||||||
|
public apiManager: ApiManager;
|
||||||
|
|
||||||
|
/** Email queue for outbound emails */
|
||||||
|
private emailQueue: Map<string, QueueEntry> = new Map();
|
||||||
|
|
||||||
|
/** Email queue processing state */
|
||||||
|
private queueProcessing = false;
|
||||||
|
|
||||||
|
/** Rate limiters for outbound emails */
|
||||||
|
private rateLimiters: Map<string, {
|
||||||
|
tokens: number;
|
||||||
|
lastRefill: number;
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
|
/** Certificate cache */
|
||||||
|
private certificate: Certificate = null;
|
||||||
|
|
||||||
|
/** MTA configuration */
|
||||||
|
private config: IMtaConfig;
|
||||||
|
|
||||||
|
/** Stats for monitoring */
|
||||||
|
private stats: MtaStats;
|
||||||
|
|
||||||
|
/** Whether the service is currently running */
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the MTA service
|
||||||
|
* @param platformServiceRefArg Reference to the platform service
|
||||||
|
* @param config Configuration options
|
||||||
|
*/
|
||||||
|
constructor(platformServiceRefArg: SzPlatformService, config: IMtaConfig = {}) {
|
||||||
|
this.platformServiceRef = platformServiceRefArg;
|
||||||
|
|
||||||
|
// Initialize with default configuration
|
||||||
|
this.config = this.getDefaultConfig();
|
||||||
|
|
||||||
|
// Merge with provided configuration
|
||||||
|
this.config = this.mergeConfig(this.config, config);
|
||||||
|
|
||||||
|
// Initialize components
|
||||||
|
this.dkimCreator = new DKIMCreator(this);
|
||||||
|
this.dkimVerifier = new DKIMVerifier(this);
|
||||||
|
this.dnsManager = new DNSManager(this);
|
||||||
|
this.apiManager = new ApiManager();
|
||||||
|
|
||||||
|
// Initialize stats
|
||||||
|
this.stats = {
|
||||||
|
startTime: new Date(),
|
||||||
|
emailsReceived: 0,
|
||||||
|
emailsSent: 0,
|
||||||
|
emailsFailed: 0,
|
||||||
|
activeConnections: 0,
|
||||||
|
queueSize: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure required directories exist
|
||||||
|
this.ensureDirectories();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default configuration
|
||||||
|
*/
|
||||||
|
private getDefaultConfig(): IMtaConfig {
|
||||||
|
return {
|
||||||
|
smtp: {
|
||||||
|
enabled: true,
|
||||||
|
port: 25,
|
||||||
|
hostname: 'mta.lossless.one',
|
||||||
|
maxSize: 10 * 1024 * 1024 // 10MB
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
domain: 'mta.lossless.one',
|
||||||
|
autoRenew: true
|
||||||
|
},
|
||||||
|
outbound: {
|
||||||
|
concurrency: 5,
|
||||||
|
retries: {
|
||||||
|
max: 3,
|
||||||
|
delay: 300000, // 5 minutes
|
||||||
|
useBackoff: true
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
maxPerPeriod: 100,
|
||||||
|
periodMs: 60000, // 1 minute
|
||||||
|
perDomain: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
useDkim: true,
|
||||||
|
verifyDkim: true,
|
||||||
|
verifySpf: true,
|
||||||
|
useTls: true,
|
||||||
|
requireValidCerts: false
|
||||||
|
},
|
||||||
|
domains: {
|
||||||
|
local: ['lossless.one'],
|
||||||
|
autoCreateDnsRecords: true,
|
||||||
|
dkimSelector: 'mta'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge configurations
|
||||||
|
*/
|
||||||
|
private mergeConfig(defaultConfig: IMtaConfig, customConfig: IMtaConfig): IMtaConfig {
|
||||||
|
// Deep merge of configurations
|
||||||
|
// (A more robust implementation would use a dedicated deep-merge library)
|
||||||
|
const merged = { ...defaultConfig };
|
||||||
|
|
||||||
|
// Merge first level
|
||||||
|
for (const [key, value] of Object.entries(customConfig)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
|
||||||
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
merged[key] = { ...merged[key], ...value };
|
||||||
|
} else {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure required directories exist
|
||||||
|
*/
|
||||||
|
private ensureDirectories(): void {
|
||||||
|
plugins.smartfile.fs.ensureDirSync(paths.keysDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(paths.logsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the MTA service
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.running) {
|
||||||
|
console.warn('MTA service is already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Starting MTA service...');
|
||||||
|
|
||||||
|
// Load or provision certificate
|
||||||
|
await this.loadOrProvisionCertificate();
|
||||||
|
|
||||||
|
// Start SMTP server if enabled
|
||||||
|
if (this.config.smtp.enabled) {
|
||||||
|
const smtpOptions: ISmtpServerOptions = {
|
||||||
|
port: this.config.smtp.port,
|
||||||
|
key: this.certificate.privateKey,
|
||||||
|
cert: this.certificate.publicKey,
|
||||||
|
hostname: this.config.smtp.hostname
|
||||||
|
};
|
||||||
|
|
||||||
|
this.server = new SMTPServer(this, smtpOptions);
|
||||||
|
this.server.start();
|
||||||
|
console.log(`SMTP server started on port ${smtpOptions.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start queue processing
|
||||||
|
this.startQueueProcessing();
|
||||||
|
|
||||||
|
// Update DNS records for local domains if configured
|
||||||
|
if (this.config.domains.autoCreateDnsRecords) {
|
||||||
|
await this.updateDnsRecordsForLocalDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
console.log('MTA service started successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start MTA service:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the MTA service
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.running) {
|
||||||
|
console.warn('MTA service is not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Stopping MTA service...');
|
||||||
|
|
||||||
|
// Stop SMTP server if running
|
||||||
|
if (this.server) {
|
||||||
|
await this.server.stop();
|
||||||
|
this.server = null;
|
||||||
|
console.log('SMTP server stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop queue processing
|
||||||
|
this.queueProcessing = false;
|
||||||
|
console.log('Email queue processing stopped');
|
||||||
|
|
||||||
|
this.running = false;
|
||||||
|
console.log('MTA service stopped successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stopping MTA service:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email (add to queue)
|
||||||
|
*/
|
||||||
|
public async send(email: Email): Promise<string> {
|
||||||
|
if (!this.running) {
|
||||||
|
throw new Error('MTA service is not running');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique ID for this email
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
this.validateEmail(email);
|
||||||
|
|
||||||
|
// Create DKIM keys if needed
|
||||||
|
if (this.config.security.useDkim) {
|
||||||
|
await this.dkimCreator.handleDKIMKeysForEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
this.emailQueue.set(id, {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
addedAt: new Date(),
|
||||||
|
processing: false,
|
||||||
|
attempts: 0,
|
||||||
|
status: DeliveryStatus.PENDING
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
this.stats.queueSize = this.emailQueue.size;
|
||||||
|
|
||||||
|
console.log(`Email added to queue: ${id}`);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status of an email in the queue
|
||||||
|
*/
|
||||||
|
public getEmailStatus(id: string): QueueEntry | null {
|
||||||
|
return this.emailQueue.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming email
|
||||||
|
*/
|
||||||
|
public async processIncomingEmail(email: Email): Promise<boolean> {
|
||||||
|
if (!this.running) {
|
||||||
|
throw new Error('MTA service is not running');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Processing incoming email from ${email.from} to ${email.to}`);
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
this.stats.emailsReceived++;
|
||||||
|
|
||||||
|
// Check if the recipient domain is local
|
||||||
|
const recipientDomain = email.to[0].split('@')[1];
|
||||||
|
const isLocalDomain = this.isLocalDomain(recipientDomain);
|
||||||
|
|
||||||
|
if (isLocalDomain) {
|
||||||
|
// Save to local mailbox
|
||||||
|
await this.saveToLocalMailbox(email);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// Forward to another server
|
||||||
|
const forwardId = await this.send(email);
|
||||||
|
console.log(`Forwarding email to ${email.to} with queue ID ${forwardId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing incoming email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain is local
|
||||||
|
*/
|
||||||
|
private isLocalDomain(domain: string): boolean {
|
||||||
|
return this.config.domains.local.includes(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an email to a local mailbox
|
||||||
|
*/
|
||||||
|
private async saveToLocalMailbox(email: Email): Promise<void> {
|
||||||
|
// Simplified implementation - in a real system, this would store to a user's mailbox
|
||||||
|
const mailboxPath = plugins.path.join(paths.receivedEmailsDir, 'local');
|
||||||
|
plugins.smartfile.fs.ensureDirSync(mailboxPath);
|
||||||
|
|
||||||
|
const emailContent = email.toRFC822String();
|
||||||
|
const filename = `${Date.now()}_${email.to[0].replace('@', '_at_')}.eml`;
|
||||||
|
|
||||||
|
plugins.smartfile.memory.toFsSync(
|
||||||
|
emailContent,
|
||||||
|
plugins.path.join(mailboxPath, filename)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Email saved to local mailbox: ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start processing the email queue
|
||||||
|
*/
|
||||||
|
private startQueueProcessing(): void {
|
||||||
|
if (this.queueProcessing) return;
|
||||||
|
|
||||||
|
this.queueProcessing = true;
|
||||||
|
this.processQueue();
|
||||||
|
console.log('Email queue processing started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process emails in the queue
|
||||||
|
*/
|
||||||
|
private async processQueue(): Promise<void> {
|
||||||
|
if (!this.queueProcessing) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get pending emails ordered by next attempt time
|
||||||
|
const pendingEmails = Array.from(this.emailQueue.values())
|
||||||
|
.filter(entry =>
|
||||||
|
(entry.status === DeliveryStatus.PENDING || entry.status === DeliveryStatus.DEFERRED) &&
|
||||||
|
!entry.processing &&
|
||||||
|
(!entry.nextAttempt || entry.nextAttempt <= new Date())
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Sort by next attempt time, then by added time
|
||||||
|
if (a.nextAttempt && b.nextAttempt) {
|
||||||
|
return a.nextAttempt.getTime() - b.nextAttempt.getTime();
|
||||||
|
} else if (a.nextAttempt) {
|
||||||
|
return 1;
|
||||||
|
} else if (b.nextAttempt) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
return a.addedAt.getTime() - b.addedAt.getTime();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine how many emails we can process concurrently
|
||||||
|
const availableSlots = Math.max(0, this.config.outbound.concurrency -
|
||||||
|
Array.from(this.emailQueue.values()).filter(e => e.processing).length);
|
||||||
|
|
||||||
|
// Process emails up to our concurrency limit
|
||||||
|
for (let i = 0; i < Math.min(availableSlots, pendingEmails.length); i++) {
|
||||||
|
const entry = pendingEmails[i];
|
||||||
|
|
||||||
|
// Check rate limits
|
||||||
|
if (!this.checkRateLimit(entry.email)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as processing
|
||||||
|
entry.processing = true;
|
||||||
|
|
||||||
|
// Process in background
|
||||||
|
this.processQueueEntry(entry).catch(error => {
|
||||||
|
console.error(`Error processing queue entry ${entry.id}:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in queue processing:', error);
|
||||||
|
} finally {
|
||||||
|
// Schedule next processing cycle
|
||||||
|
setTimeout(() => this.processQueue(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single queue entry
|
||||||
|
*/
|
||||||
|
private async processQueueEntry(entry: QueueEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`Processing queue entry ${entry.id}`);
|
||||||
|
|
||||||
|
// Update attempt counters
|
||||||
|
entry.attempts++;
|
||||||
|
entry.lastAttempt = new Date();
|
||||||
|
|
||||||
|
// Create send job
|
||||||
|
const sendJob = new EmailSendJob(this, entry.email, {
|
||||||
|
maxRetries: 1, // We handle retries at the queue level
|
||||||
|
tlsOptions: {
|
||||||
|
rejectUnauthorized: this.config.security.requireValidCerts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
const status = await sendJob.send();
|
||||||
|
entry.status = status;
|
||||||
|
|
||||||
|
if (status === DeliveryStatus.DELIVERED) {
|
||||||
|
// Success - remove from queue
|
||||||
|
this.emailQueue.delete(entry.id);
|
||||||
|
this.stats.emailsSent++;
|
||||||
|
console.log(`Email ${entry.id} delivered successfully`);
|
||||||
|
} else if (status === DeliveryStatus.FAILED) {
|
||||||
|
// Permanent failure
|
||||||
|
entry.error = sendJob.deliveryInfo.error;
|
||||||
|
this.stats.emailsFailed++;
|
||||||
|
console.log(`Email ${entry.id} failed permanently: ${entry.error.message}`);
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
this.emailQueue.delete(entry.id);
|
||||||
|
} else if (status === DeliveryStatus.DEFERRED) {
|
||||||
|
// Temporary failure - schedule retry if attempts remain
|
||||||
|
entry.error = sendJob.deliveryInfo.error;
|
||||||
|
|
||||||
|
if (entry.attempts >= this.config.outbound.retries.max) {
|
||||||
|
// Max retries reached - mark as failed
|
||||||
|
entry.status = DeliveryStatus.FAILED;
|
||||||
|
this.stats.emailsFailed++;
|
||||||
|
console.log(`Email ${entry.id} failed after ${entry.attempts} attempts: ${entry.error.message}`);
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
this.emailQueue.delete(entry.id);
|
||||||
|
} else {
|
||||||
|
// Schedule retry
|
||||||
|
const delay = this.calculateRetryDelay(entry.attempts);
|
||||||
|
entry.nextAttempt = new Date(Date.now() + delay);
|
||||||
|
console.log(`Email ${entry.id} deferred, next attempt at ${entry.nextAttempt}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Unexpected error processing queue entry ${entry.id}:`, error);
|
||||||
|
|
||||||
|
// Handle unexpected errors similarly to deferred
|
||||||
|
entry.error = error;
|
||||||
|
|
||||||
|
if (entry.attempts >= this.config.outbound.retries.max) {
|
||||||
|
entry.status = DeliveryStatus.FAILED;
|
||||||
|
this.stats.emailsFailed++;
|
||||||
|
this.emailQueue.delete(entry.id);
|
||||||
|
} else {
|
||||||
|
entry.status = DeliveryStatus.DEFERRED;
|
||||||
|
const delay = this.calculateRetryDelay(entry.attempts);
|
||||||
|
entry.nextAttempt = new Date(Date.now() + delay);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Mark as no longer processing
|
||||||
|
entry.processing = false;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
this.stats.queueSize = this.emailQueue.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate delay before retry based on attempt number
|
||||||
|
*/
|
||||||
|
private calculateRetryDelay(attemptNumber: number): number {
|
||||||
|
const baseDelay = this.config.outbound.retries.delay;
|
||||||
|
|
||||||
|
if (this.config.outbound.retries.useBackoff) {
|
||||||
|
// Exponential backoff: base_delay * (2^(attempt-1))
|
||||||
|
return baseDelay * Math.pow(2, attemptNumber - 1);
|
||||||
|
} else {
|
||||||
|
return baseDelay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an email can be sent under rate limits
|
||||||
|
*/
|
||||||
|
private checkRateLimit(email: Email): boolean {
|
||||||
|
const config = this.config.outbound.rateLimit;
|
||||||
|
if (!config || !config.maxPerPeriod) {
|
||||||
|
return true; // No rate limit configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which limiter to use
|
||||||
|
const key = config.perDomain ? email.getFromDomain() : 'global';
|
||||||
|
|
||||||
|
// Initialize limiter if needed
|
||||||
|
if (!this.rateLimiters.has(key)) {
|
||||||
|
this.rateLimiters.set(key, {
|
||||||
|
tokens: config.maxPerPeriod,
|
||||||
|
lastRefill: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const limiter = this.rateLimiters.get(key);
|
||||||
|
|
||||||
|
// Refill tokens based on time elapsed
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsedMs = now - limiter.lastRefill;
|
||||||
|
const tokensToAdd = Math.floor(elapsedMs / config.periodMs) * config.maxPerPeriod;
|
||||||
|
|
||||||
|
if (tokensToAdd > 0) {
|
||||||
|
limiter.tokens = Math.min(config.maxPerPeriod, limiter.tokens + tokensToAdd);
|
||||||
|
limiter.lastRefill = now - (elapsedMs % config.periodMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have tokens available
|
||||||
|
if (limiter.tokens > 0) {
|
||||||
|
limiter.tokens--;
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log(`Rate limit exceeded for ${key}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load or provision a TLS certificate
|
||||||
|
*/
|
||||||
|
private async loadOrProvisionCertificate(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if we have manual cert paths specified
|
||||||
|
if (this.config.tls.keyPath && this.config.tls.certPath) {
|
||||||
|
console.log('Using manually specified certificate files');
|
||||||
|
|
||||||
|
const [privateKey, publicKey] = await Promise.all([
|
||||||
|
plugins.fs.promises.readFile(this.config.tls.keyPath, 'utf-8'),
|
||||||
|
plugins.fs.promises.readFile(this.config.tls.certPath, 'utf-8')
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.certificate = {
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
expiresAt: this.getCertificateExpiry(publicKey)
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Certificate loaded, expires: ${this.certificate.expiresAt}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use auto-provisioning
|
||||||
|
console.log(`Provisioning certificate for ${this.config.tls.domain}`);
|
||||||
|
this.certificate = await this.provisionCertificate(this.config.tls.domain);
|
||||||
|
console.log(`Certificate provisioned, expires: ${this.certificate.expiresAt}`);
|
||||||
|
|
||||||
|
// Set up auto-renewal if configured
|
||||||
|
if (this.config.tls.autoRenew) {
|
||||||
|
this.setupCertificateRenewal();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading or provisioning certificate:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision a certificate from the certificate service
|
||||||
|
*/
|
||||||
|
private async provisionCertificate(domain: string): Promise<Certificate> {
|
||||||
|
try {
|
||||||
|
// Setup proper authentication
|
||||||
|
const authToken = await this.getAuthToken();
|
||||||
|
|
||||||
|
if (!authToken) {
|
||||||
|
throw new Error('Failed to obtain authentication token for certificate provisioning');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize client
|
||||||
|
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
|
||||||
|
typedrouter,
|
||||||
|
'https://cloudly.lossless.one:443'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request certificate
|
||||||
|
const typedCertificateRequest = typedsocketClient.createTypedRequest<any>('getSslCertificate');
|
||||||
|
const typedResponse = await typedCertificateRequest.fire({
|
||||||
|
authToken,
|
||||||
|
requiredCertName: domain,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!typedResponse || !typedResponse.certificate) {
|
||||||
|
throw new Error('Invalid response from certificate service');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract certificate information
|
||||||
|
const cert = typedResponse.certificate;
|
||||||
|
|
||||||
|
// Determine expiry date
|
||||||
|
const expiresAt = this.getCertificateExpiry(cert.publicKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: cert.privateKey,
|
||||||
|
publicKey: cert.publicKey,
|
||||||
|
expiresAt
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Always close the client
|
||||||
|
await typedsocketClient.stop();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Certificate provisioning failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authentication token for certificate service
|
||||||
|
*/
|
||||||
|
private async getAuthToken(): Promise<string> {
|
||||||
|
// Implementation would depend on authentication mechanism
|
||||||
|
// This is a simplified example assuming the platform service has an auth method
|
||||||
|
try {
|
||||||
|
// For now, return a placeholder token - in production this would
|
||||||
|
// authenticate properly with the certificate service
|
||||||
|
return 'mta-service-auth-token';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to obtain auth token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract certificate expiry date from public key
|
||||||
|
*/
|
||||||
|
private getCertificateExpiry(publicKey: string): Date {
|
||||||
|
try {
|
||||||
|
// This is a simplified implementation
|
||||||
|
// In a real system, you would parse the certificate properly
|
||||||
|
// using a certificate parsing library
|
||||||
|
|
||||||
|
// For now, set expiry to 90 days from now
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||||
|
return expiresAt;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to extract certificate expiry:', error);
|
||||||
|
|
||||||
|
// Default to 30 days from now
|
||||||
|
const defaultExpiry = new Date();
|
||||||
|
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
|
||||||
|
return defaultExpiry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up certificate auto-renewal
|
||||||
|
*/
|
||||||
|
private setupCertificateRenewal(): void {
|
||||||
|
if (!this.certificate || !this.certificate.expiresAt) {
|
||||||
|
console.warn('Cannot setup certificate renewal: no valid certificate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate time until renewal (30 days before expiry)
|
||||||
|
const now = new Date();
|
||||||
|
const renewalDate = new Date(this.certificate.expiresAt);
|
||||||
|
renewalDate.setDate(renewalDate.getDate() - 30);
|
||||||
|
|
||||||
|
const timeUntilRenewal = Math.max(0, renewalDate.getTime() - now.getTime());
|
||||||
|
|
||||||
|
console.log(`Certificate renewal scheduled for ${renewalDate}`);
|
||||||
|
|
||||||
|
// Schedule renewal
|
||||||
|
setTimeout(() => {
|
||||||
|
this.renewCertificate().catch(error => {
|
||||||
|
console.error('Certificate renewal failed:', error);
|
||||||
|
});
|
||||||
|
}, timeUntilRenewal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew the certificate
|
||||||
|
*/
|
||||||
|
private async renewCertificate(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Renewing certificate...');
|
||||||
|
|
||||||
|
// Provision new certificate
|
||||||
|
const newCertificate = await this.provisionCertificate(this.config.tls.domain);
|
||||||
|
|
||||||
|
// Replace current certificate
|
||||||
|
this.certificate = newCertificate;
|
||||||
|
|
||||||
|
console.log(`Certificate renewed, new expiry: ${newCertificate.expiresAt}`);
|
||||||
|
|
||||||
|
// Update SMTP server with new certificate if running
|
||||||
|
if (this.server) {
|
||||||
|
// Restart server with new certificate
|
||||||
|
await this.server.stop();
|
||||||
|
|
||||||
|
const smtpOptions: ISmtpServerOptions = {
|
||||||
|
port: this.config.smtp.port,
|
||||||
|
key: this.certificate.privateKey,
|
||||||
|
cert: this.certificate.publicKey,
|
||||||
|
hostname: this.config.smtp.hostname
|
||||||
|
};
|
||||||
|
|
||||||
|
this.server = new SMTPServer(this, smtpOptions);
|
||||||
|
this.server.start();
|
||||||
|
|
||||||
|
console.log('SMTP server restarted with new certificate');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next renewal
|
||||||
|
this.setupCertificateRenewal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Certificate renewal failed:', error);
|
||||||
|
|
||||||
|
// Schedule retry after 24 hours
|
||||||
|
setTimeout(() => {
|
||||||
|
this.renewCertificate().catch(err => {
|
||||||
|
console.error('Certificate renewal retry failed:', err);
|
||||||
|
});
|
||||||
|
}, 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update DNS records for all local domains
|
||||||
|
*/
|
||||||
|
private async updateDnsRecordsForLocalDomains(): Promise<void> {
|
||||||
|
if (!this.config.domains.local || this.config.domains.local.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Updating DNS records for local domains...');
|
||||||
|
|
||||||
|
for (const domain of this.config.domains.local) {
|
||||||
|
try {
|
||||||
|
console.log(`Updating DNS records for ${domain}`);
|
||||||
|
|
||||||
|
// Generate DKIM keys if needed
|
||||||
|
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
||||||
|
|
||||||
|
// Generate all recommended DNS records
|
||||||
|
const records = await this.dnsManager.generateAllRecommendedRecords(domain);
|
||||||
|
|
||||||
|
console.log(`Generated ${records.length} DNS records for ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating DNS records for ${domain}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an email before sending
|
||||||
|
*/
|
||||||
|
private validateEmail(email: Email): void {
|
||||||
|
// The Email class constructor already performs basic validation
|
||||||
|
// Here we can add additional MTA-specific validation
|
||||||
|
|
||||||
|
if (!email.from) {
|
||||||
|
throw new Error('Email must have a sender address');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.to || email.to.length === 0) {
|
||||||
|
throw new Error('Email must have at least one recipient');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the sender domain is allowed
|
||||||
|
const senderDomain = email.getFromDomain();
|
||||||
|
if (!senderDomain) {
|
||||||
|
throw new Error('Invalid sender domain');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the sender domain is one of our local domains, ensure we have DKIM keys
|
||||||
|
if (this.isLocalDomain(senderDomain) && this.config.security.useDkim) {
|
||||||
|
// DKIM keys will be created if needed in the send method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MTA service statistics
|
||||||
|
*/
|
||||||
|
public getStats(): MtaStats {
|
||||||
|
// Update queue size
|
||||||
|
this.stats.queueSize = this.emailQueue.size;
|
||||||
|
|
||||||
|
// Update certificate info if available
|
||||||
|
if (this.certificate) {
|
||||||
|
const now = new Date();
|
||||||
|
const daysUntilExpiry = Math.floor(
|
||||||
|
(this.certificate.expiresAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.stats.certificateInfo = {
|
||||||
|
domain: this.config.tls.domain,
|
||||||
|
expiresAt: this.certificate.expiresAt,
|
||||||
|
daysUntilExpiry
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...this.stats };
|
||||||
|
}
|
||||||
|
}
|
476
ts/mta/classes.smtpserver.ts
Normal file
476
ts/mta/classes.smtpserver.ts
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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 handleNewConnection(socket: plugins.net.Socket): void {
|
||||||
|
console.log(`New connection from ${socket.remoteAddress}:${socket.remotePort}`);
|
||||||
|
|
||||||
|
// Initialize a new session
|
||||||
|
this.sessions.set(socket, {
|
||||||
|
state: SmtpState.GREETING,
|
||||||
|
clientHostname: '',
|
||||||
|
mailFrom: '',
|
||||||
|
rcptTo: [],
|
||||||
|
emailData: '',
|
||||||
|
useTLS: false,
|
||||||
|
connectionEnded: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send greeting
|
||||||
|
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
|
||||||
|
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
this.processData(socket, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('end', () => {
|
||||||
|
console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`);
|
||||||
|
const session = this.sessions.get(socket);
|
||||||
|
if (session) {
|
||||||
|
session.connectionEnded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
console.error(`Socket error: ${err.message}`);
|
||||||
|
this.sessions.delete(socket);
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`);
|
||||||
|
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) {
|
||||||
|
return this.processEmailData(socket, data.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): 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;
|
||||||
|
|
||||||
|
// Verifying the email with DKIM
|
||||||
|
try {
|
||||||
|
const isVerified = await this.mtaRef.dkimVerifier.verify(session.emailData);
|
||||||
|
mightBeSpam = !isVerified;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to verify DKIM signature:', error);
|
||||||
|
mightBeSpam = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process or forward the email as needed
|
||||||
|
// this.mtaRef.processIncomingEmail(email); // You could add this method to your MTA service
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing email:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
7
ts/mta/index.ts
Normal file
7
ts/mta/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export * from './classes.dkimcreator.js';
|
||||||
|
export * from './classes.emailsignjob.js';
|
||||||
|
export * from './classes.dkimverifier.js';
|
||||||
|
export * from './classes.mta.js';
|
||||||
|
export * from './classes.smtpserver.js';
|
||||||
|
export * from './classes.emailsendjob.js';
|
||||||
|
export * from './classes.email.js';
|
29
ts/paths.ts
Normal file
29
ts/paths.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
|
||||||
|
// Base directories
|
||||||
|
export const baseDir = process.cwd();
|
||||||
|
export const packageDir = plugins.path.join(
|
||||||
|
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||||
|
'../'
|
||||||
|
);
|
||||||
|
export const dataDir = plugins.path.join(baseDir, 'data');
|
||||||
|
|
||||||
|
// MTA directories
|
||||||
|
export const keysDir = plugins.path.join(dataDir, 'keys');
|
||||||
|
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
|
||||||
|
export const sentEmailsDir = plugins.path.join(dataDir, 'emails', 'sent');
|
||||||
|
export const receivedEmailsDir = plugins.path.join(dataDir, 'emails', 'received');
|
||||||
|
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
|
||||||
|
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
|
||||||
|
|
||||||
|
// Create directories if they don't exist
|
||||||
|
export function ensureDirectories() {
|
||||||
|
// Ensure data directories
|
||||||
|
plugins.smartfile.fs.ensureDirSync(dataDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(keysDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(dnsRecordsDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(sentEmailsDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(receivedEmailsDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(failedEmailsDir);
|
||||||
|
plugins.smartfile.fs.ensureDirSync(logsDir);
|
||||||
|
}
|
@ -1,6 +0,0 @@
|
|||||||
import * as plugins from './platformservice.plugins.js';
|
|
||||||
|
|
||||||
export const packageDir = plugins.path.join(
|
|
||||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
|
||||||
'../'
|
|
||||||
);
|
|
@ -1,24 +0,0 @@
|
|||||||
// node native
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
export {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
// @api.global scope
|
|
||||||
import * as typedrequest from '@api.global/typedrequest';
|
|
||||||
import * as typedserver from '@api.global/typedserver';
|
|
||||||
|
|
||||||
export {
|
|
||||||
typedrequest,
|
|
||||||
typedserver,
|
|
||||||
}
|
|
||||||
|
|
||||||
// pushrocks scope
|
|
||||||
// pushrocks scope
|
|
||||||
import * as projectinfo from '@push.rocks/projectinfo';
|
|
||||||
import * as qenv from '@push.rocks/qenv';
|
|
||||||
import * as smartdata from '@push.rocks/smartdata';
|
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
|
||||||
|
|
||||||
export { projectinfo, qenv, smartdata, smartpath };
|
|
44
ts/platformservice.ts
Normal file
44
ts/platformservice.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as paths from './paths.js';
|
||||||
|
import { PlatformServiceDb } from './classes.platformservicedb.js'
|
||||||
|
import { EmailService } from './email/classes.emailservice.js';
|
||||||
|
import { SmsService } from './sms/classes.smsservice.js';
|
||||||
|
import { LetterService } from './letter/classes.letterservice.js';
|
||||||
|
import { MtaService } from './mta/classes.mta.js';
|
||||||
|
|
||||||
|
export class SzPlatformService {
|
||||||
|
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||||
|
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
||||||
|
public platformserviceDb: PlatformServiceDb;
|
||||||
|
|
||||||
|
public typedserver: plugins.typedserver.TypedServer;
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
// SubServices
|
||||||
|
public emailService: EmailService;
|
||||||
|
public letterService: LetterService;
|
||||||
|
public mtaService: MtaService;
|
||||||
|
public smsService: SmsService;
|
||||||
|
|
||||||
|
public async start() {
|
||||||
|
this.platformserviceDb = new PlatformServiceDb(this);
|
||||||
|
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
||||||
|
|
||||||
|
// lets start the sub services
|
||||||
|
this.emailService = new EmailService(this);
|
||||||
|
this.letterService = new LetterService(this, {
|
||||||
|
letterxpressUser: await this.serviceQenv.getEnvVarOnDemand('LETTER_API_USER'),
|
||||||
|
letterxpressToken: await this.serviceQenv.getEnvVarOnDemand('LETTER_API_TOKEN')
|
||||||
|
});
|
||||||
|
this.mtaService = new MtaService(this);
|
||||||
|
this.smsService = new SmsService(this, {
|
||||||
|
apiGatewayApiToken: await this.serviceQenv.getEnvVarOnDemand('SMS_API_TOKEN'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// lets start the server finally
|
||||||
|
this.typedserver = new plugins.typedserver.TypedServer({
|
||||||
|
cors: true,
|
||||||
|
});
|
||||||
|
await this.typedserver.start();
|
||||||
|
}
|
||||||
|
}
|
81
ts/plugins.ts
Normal file
81
ts/plugins.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
// node native
|
||||||
|
import * as dns from 'dns';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as http from 'http';
|
||||||
|
import * as net from 'net';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as tls from 'tls';
|
||||||
|
import * as util from 'util';
|
||||||
|
|
||||||
|
export {
|
||||||
|
dns,
|
||||||
|
fs,
|
||||||
|
crypto,
|
||||||
|
http,
|
||||||
|
net,
|
||||||
|
path,
|
||||||
|
tls,
|
||||||
|
util,
|
||||||
|
}
|
||||||
|
|
||||||
|
// @serve.zone scope
|
||||||
|
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
||||||
|
|
||||||
|
export {
|
||||||
|
servezoneInterfaces
|
||||||
|
}
|
||||||
|
|
||||||
|
// @api.global scope
|
||||||
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
|
import * as typedserver from '@api.global/typedserver';
|
||||||
|
import * as typedsocket from '@api.global/typedsocket';
|
||||||
|
|
||||||
|
export {
|
||||||
|
typedrequest,
|
||||||
|
typedserver,
|
||||||
|
typedsocket,
|
||||||
|
}
|
||||||
|
|
||||||
|
// @push.rocks scope
|
||||||
|
import * as projectinfo from '@push.rocks/projectinfo';
|
||||||
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
|
import * as smartmail from '@push.rocks/smartmail';
|
||||||
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
import * as smartproxy from '@push.rocks/smartproxy';
|
||||||
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
|
import * as smartrule from '@push.rocks/smartrule';
|
||||||
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
|
||||||
|
export { projectinfo, qenv, smartdata, smartfile, smartlog, smartmail, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx };
|
||||||
|
|
||||||
|
// apiclient.xyz scope
|
||||||
|
import * as letterxpress from '@apiclient.xyz/letterxpress';
|
||||||
|
|
||||||
|
export {
|
||||||
|
letterxpress,
|
||||||
|
}
|
||||||
|
|
||||||
|
// tsclass scope
|
||||||
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
|
||||||
|
export {
|
||||||
|
tsclass,
|
||||||
|
}
|
||||||
|
|
||||||
|
// third party
|
||||||
|
import * as mailauth from 'mailauth';
|
||||||
|
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
||||||
|
import mailparser from 'mailparser';
|
||||||
|
import * as uuid from 'uuid';
|
||||||
|
|
||||||
|
export {
|
||||||
|
mailauth,
|
||||||
|
dkimSign,
|
||||||
|
mailparser,
|
||||||
|
uuid,
|
||||||
|
}
|
89
ts/sms/classes.smsservice.ts
Normal file
89
ts/sms/classes.smsservice.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as paths from '../paths.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { SzPlatformService } from '../platformservice.js';
|
||||||
|
|
||||||
|
export interface ISmsConstructorOptions {
|
||||||
|
apiGatewayApiToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SmsService {
|
||||||
|
public platformServiceRef: SzPlatformService;
|
||||||
|
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
public options: ISmsConstructorOptions;
|
||||||
|
|
||||||
|
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ISmsConstructorOptions) {
|
||||||
|
this.platformServiceRef = platformServiceRefArg;
|
||||||
|
this.options = optionsArg;
|
||||||
|
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* starts the financeflow instance
|
||||||
|
*/
|
||||||
|
public async start() {
|
||||||
|
logger.log('info', `starting sms service`);
|
||||||
|
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendSms>(
|
||||||
|
'sendSms',
|
||||||
|
async (reqData) => {
|
||||||
|
await this.sendSms(reqData.toNumber, reqData.fromName, reqData.messageText);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.platformservice.sms.IRequest_SendVerificationCode>(
|
||||||
|
'sendVerificationCode',
|
||||||
|
async (reqData) => {
|
||||||
|
const verificationCode = (
|
||||||
|
await this.sendVerificationCode(reqData.toNumber, reqData.fromName)
|
||||||
|
).toString();
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
verificationCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendSms(toNumber: number, fromName: string, messageText: string) {
|
||||||
|
const payload = {
|
||||||
|
sender: fromName,
|
||||||
|
message: messageText,
|
||||||
|
recipients: [{ msisdn: toNumber }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await plugins.smartrequest.request('https://gatewayapi.com/rest/mtsms', {
|
||||||
|
method: 'POST',
|
||||||
|
requestBody: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${Buffer.from(`${this.options.apiGatewayApiToken}:`).toString('base64')}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await resp.body;
|
||||||
|
logger.log('info', `sent an sms to ${toNumber} with text '${messageText}'`, {
|
||||||
|
eventType: 'sentSms',
|
||||||
|
sms: {
|
||||||
|
fromName: fromName,
|
||||||
|
toNumber: toNumber.toString(),
|
||||||
|
messageText: messageText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(json, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendVerificationCode(toNumber: number, fromName: string) {
|
||||||
|
let verificationCode = Math.floor(100000 + Math.random() * 900000);
|
||||||
|
await this.sendSms(toNumber, fromName, `Your verification code: ${verificationCode}`);
|
||||||
|
return verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {}
|
||||||
|
}
|
1
ts/sms/index.ts
Normal file
1
ts/sms/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './classes.smsservice.js';
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
"useDefineForClassFields": false,
|
"useDefineForClassFields": false,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user