Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
2b207833ce | |||
4dc095e662 | |||
c1311f493f | |||
97cbe6e398 | |||
0bb9c5e1e5 | |||
cf90560243 | |||
8def86494a | |||
db46e01f6e | |||
7baf747972 | |||
4a17a1073e | |||
8997ded81d | |||
f177d8e9ab | |||
808a9cc856 | |||
be1c8d1164 | |||
2ecb2f3aa0 | |||
01dcdebda5 | |||
2adcc249de | |||
543e696bfc | |||
796e0204ca | |||
f5a36ab53a |
39
changelog.md
Normal file
39
changelog.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## 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",
|
||||
"gitscope": "serve.zone",
|
||||
"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",
|
||||
"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": {
|
||||
|
59
package.json
59
package.json
@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@serve.zone/platformservice",
|
||||
"version": "1.0.2",
|
||||
"description": "contains the platformservice container with mail, sms, letter, ai services.",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
@ -9,24 +10,62 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"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)",
|
||||
"build": "(tsbuild --web --allowimplicitany)"
|
||||
"localPublish": ""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.17",
|
||||
"@git.zone/tsrun": "^1.2.8",
|
||||
"@git.zone/tstest": "^1.0.28",
|
||||
"@git.zone/tstest": "^1.0.88",
|
||||
"@git.zone/tswatch": "^2.0.1",
|
||||
"@push.rocks/tapbundle": "^5.0.3"
|
||||
"@push.rocks/tapbundle": "^5.0.22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.0.4",
|
||||
"@api.global/typedserver": "^3.0.20",
|
||||
"@anthropic-ai/sdk": "^0.18.0",
|
||||
"@api.global/typedrequest": "^3.0.19",
|
||||
"@api.global/typedserver": "^3.0.27",
|
||||
"@api.global/typedsocket": "^3.0.0",
|
||||
"@apiclient.xyz/cloudflare": "^6.0.3",
|
||||
"@apiclient.xyz/letterxpress": "^1.0.17",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@push.rocks/qenv": "^6.0.5",
|
||||
"@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/smartstate": "^2.0.0"
|
||||
}
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartrequest": "^2.0.21",
|
||||
"@push.rocks/smartrx": "^3.0.7",
|
||||
"@push.rocks/smartstate": "^2.0.0",
|
||||
"@serve.zone/interfaces": "^1.0.47",
|
||||
"@tsclass/tsclass": "^4.0.52",
|
||||
"mailauth": "^4.6.5",
|
||||
"mailparser": "^3.6.9",
|
||||
"openai": "^4.29.2",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
13558
pnpm-lock.yaml
generated
13558
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
141
readme.md
141
readme.md
@ -1,31 +1,126 @@
|
||||
# @serve.zone/platformservice
|
||||
|
||||
contains the platformservice container with mail, sms, letter, ai services.
|
||||
|
||||
## Availabililty and Links
|
||||
* [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/)
|
||||
## Install
|
||||
|
||||
## Status for master
|
||||
To install `@serve.zone/platformservice`, run the following command:
|
||||
|
||||
Status Category | Status Badge
|
||||
-- | --
|
||||
GitLab Pipelines | [](https://lossless.cloud)
|
||||
GitLab Pipline Test Coverage | [](https://lossless.cloud)
|
||||
npm | [](https://lossless.cloud)
|
||||
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)
|
||||
```sh
|
||||
npm install @serve.zone/platformservice --save
|
||||
```
|
||||
|
||||
Make sure you have Node.js and npm installed on your system to use this package.
|
||||
|
||||
## Usage
|
||||
Use TypeScript for best in class intellisense.
|
||||
For further information read the linked docs at the top of this readme.
|
||||
|
||||
## Legal
|
||||
> MIT licensed | **©** [Task Venture Capital GmbH](https://task.vc)
|
||||
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
|
||||
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.
|
||||
|
||||
### 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();
|
||||
```
|
||||
|
||||
### 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, and artificial intelligence seamlessly.
|
||||
undefined
|
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 = {
|
||||
name: '@serve.zone/platformservice',
|
||||
version: '1.0.2',
|
||||
description: 'contains the platformservice container with mail, sms, letter, ai services.'
|
||||
version: '1.1.0',
|
||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
||||
}
|
||||
|
50
ts/aibridge/aibridge.classes.aibridge.ts
Normal file
50
ts/aibridge/aibridge.classes.aibridge.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import * as plugins from './aibridge.plugins.js';
|
||||
import * as paths from './aibridge.paths.js';
|
||||
import { AiBridgeDb } from './aibridge.classes.aibridgedb.js';
|
||||
import { OpenAiBridge } from './aibridge.classes.openaibridge.js';
|
||||
|
||||
export class AiBridge {
|
||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||
public serverInstance: plugins.loleServiceserver.ServiceServer;
|
||||
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
||||
public aibridgeDb: AiBridgeDb;
|
||||
|
||||
public openAiBridge: OpenAiBridge;
|
||||
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public async start() {
|
||||
this.aibridgeDb = new AiBridgeDb(this);
|
||||
await this.aibridgeDb.start();
|
||||
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
||||
this.openAiBridge = new OpenAiBridge(this);
|
||||
await this.openAiBridge.start();
|
||||
|
||||
// server
|
||||
this.serverInstance = new plugins.loleServiceserver.ServiceServer({
|
||||
serviceDomain: 'aibridge.lossless.one',
|
||||
serviceName: 'aibridge',
|
||||
serviceVersion: this.projectinfo.npm.version,
|
||||
addCustomRoutes: async (serverArg) => {
|
||||
// any custom route configs go here
|
||||
},
|
||||
});
|
||||
|
||||
// lets implemenet the actual typedrequest functions
|
||||
this.typedrouter.addTypedHandler<plugins.lointAiBridge.requests.IReq_Chat>(new plugins.typedrequest.TypedHandler('chat', async reqArg => {
|
||||
const resultChat = await this.openAiBridge.chat(reqArg.chat.systemMessage, reqArg.chat.messages[reqArg.chat.messages.length - 1].content, reqArg.chat.messages);
|
||||
return {
|
||||
chat: reqArg.chat,
|
||||
latestMessage: resultChat.message.content,
|
||||
}
|
||||
}))
|
||||
|
||||
await this.serverInstance.start();
|
||||
this.serverInstance.typedServer.typedrouter.addTypedRouter(this.typedrouter);
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.serverInstance.stop();
|
||||
await this.aibridgeDb.stop();
|
||||
}
|
||||
}
|
25
ts/aibridge/aibridge.classes.aibridgedb.ts
Normal file
25
ts/aibridge/aibridge.classes.aibridgedb.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import * as plugins from './aibridge.plugins.js';
|
||||
import { AiBridge } from './aibridge.classes.aibridge.js';
|
||||
|
||||
export class AiBridgeDb {
|
||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
public aibridgeRef: AiBridge;
|
||||
|
||||
constructor(aibridgeRefArg: AiBridge) {
|
||||
this.aibridgeRef = aibridgeRefArg;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUser: await this.aibridgeRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
|
||||
mongoDbName: await this.aibridgeRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
|
||||
mongoDbPass: await this.aibridgeRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
|
||||
mongoDbUrl: await this.aibridgeRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
}
|
58
ts/aibridge/aibridge.classes.openaibridge.ts
Normal file
58
ts/aibridge/aibridge.classes.openaibridge.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { AiBridge } from './aibridge.classes.aibridge.js';
|
||||
import * as plugins from './aibridge.plugins.js';
|
||||
import * as paths from './aibridge.paths.js';
|
||||
|
||||
export class OpenAiBridge {
|
||||
public aiBridgeRef: AiBridge;
|
||||
public openAiApiClient: plugins.openai.default;
|
||||
constructor(aiBridgeRefArg: AiBridge) {
|
||||
this.aiBridgeRef = aiBridgeRefArg;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
const openAiToken = await this.aiBridgeRef.serviceQenv.getEnvVarOnDemand('OPENAI_TOKEN');
|
||||
this.openAiApiClient = new plugins.openai.default({
|
||||
apiKey: openAiToken,
|
||||
dangerouslyAllowBrowser: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async stop() {}
|
||||
|
||||
public async chat(
|
||||
systemMessage: string,
|
||||
userMessage: string,
|
||||
messageHistory: {
|
||||
role: 'assistant' | 'user';
|
||||
content: string;
|
||||
}[]
|
||||
) {
|
||||
const result = await this.openAiApiClient.chat.completions.create({
|
||||
model: 'gpt-4-turbo-preview',
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
...messageHistory,
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
});
|
||||
return {
|
||||
message: result.choices[0].message,
|
||||
};
|
||||
}
|
||||
|
||||
public async audio(messageArg: string) {
|
||||
const done = plugins.smartpromise.defer();
|
||||
const result = await this.openAiApiClient.audio.speech.create({
|
||||
model: 'tts-1-hd',
|
||||
input: messageArg,
|
||||
voice: 'nova',
|
||||
response_format: 'mp3',
|
||||
speed: 1,
|
||||
});
|
||||
const stream = result.body.pipe(plugins.smartfile.fsStream.createWriteStream(plugins.path.join(paths.nogitDir, 'output.mp3')));
|
||||
stream.on('finish', () => {
|
||||
done.resolve();
|
||||
});
|
||||
return done.promise;
|
||||
}
|
||||
}
|
16
ts/aibridge/aibridge.paths.ts
Normal file
16
ts/aibridge/aibridge.paths.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import * as plugins from './aibridge.plugins.js';
|
||||
|
||||
export const packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
);
|
||||
|
||||
export const assetsDir = plugins.path.join(
|
||||
packageDir,
|
||||
'./assets/'
|
||||
);
|
||||
|
||||
export const nogitDir = plugins.path.join(
|
||||
packageDir,
|
||||
'./.nogit/'
|
||||
);
|
32
ts/aibridge/aibridge.plugins.ts
Normal file
32
ts/aibridge/aibridge.plugins.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// node native
|
||||
import * as path from 'path';
|
||||
|
||||
export { path };
|
||||
|
||||
// @losslessone_private scope
|
||||
import * as loleServiceserver from '@losslessone_private/lole-serviceserver';
|
||||
import * as lointAiBridge from '@losslessone_private/loint-aibridge';
|
||||
|
||||
export { loleServiceserver, lointAiBridge };
|
||||
|
||||
// apiglobal scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
export {
|
||||
typedrequest,
|
||||
}
|
||||
|
||||
// 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 smartfile from '@push.rocks/smartfile';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
|
||||
export { projectinfo, qenv, smartdata, smartfile, smartpath, smartpromise };
|
||||
|
||||
// thirdparty scope
|
||||
import * as antrophic from '@anthropic-ai/sdk';
|
||||
import * as openai from 'openai';
|
||||
export { antrophic as anthropic, openai };
|
17
ts/aibridge/index.ts
Normal file
17
ts/aibridge/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { AiBridge } from './aibridge.classes.aibridge.js';
|
||||
|
||||
export {
|
||||
AiBridge,
|
||||
}
|
||||
|
||||
let aibridgeInstance: AiBridge;
|
||||
export const runCli = async () => {
|
||||
aibridgeInstance = new AiBridge();
|
||||
await aibridgeInstance.start();
|
||||
};
|
||||
|
||||
export const stop = async () => {
|
||||
if (aibridgeInstance) {
|
||||
await aibridgeInstance.stop();
|
||||
}
|
||||
};
|
@ -1,6 +1,10 @@
|
||||
import * as plugins from './platformservice.plugins.js';
|
||||
import * as paths from './platformservice.paths.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { PlatformServiceDb } from './classes.platformservicedb.js'
|
||||
import { EmailService } from './email/email.classes.emailservice.js';
|
||||
import { SmsService } from './sms/smsservice.js';
|
||||
import { LetterService } from './letter/classes.letterservice.js';
|
||||
import { MtaService } from './mta/mta.classes.mta.js';
|
||||
|
||||
export class SzPlatformService {
|
||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||
@ -10,9 +14,28 @@ export class SzPlatformService {
|
||||
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,
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import * as plugins from './platformservice.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import { SzPlatformService } from './classes.platformservice.js';
|
||||
|
||||
|
||||
|
@ -1,17 +1,16 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import { Email } from './email.classes.email.js';
|
||||
import { request } from 'http';
|
||||
import { logger } from './email.logging.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EmailService } from './email.classes.emailservice.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export class ApiManager {
|
||||
public emailRef: Email;
|
||||
public emailRef: EmailService;
|
||||
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(emailRefArg: Email) {
|
||||
constructor(emailRefArg: EmailService) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.emailRef.mainTypedRouter.addTypedRouter(this.typedRouter);
|
||||
this.typedRouter.addTypedHandler<plugins.lointEmail.IRequestSendEmail>(
|
||||
this.emailRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
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,
|
||||
@ -22,7 +21,7 @@ export class ApiManager {
|
||||
if (requestData.attachments) {
|
||||
for (const attachment of requestData.attachments) {
|
||||
mailToSend.addAttachment(
|
||||
await plugins.smartfile.Smartfile.fromString(
|
||||
await plugins.smartfile.SmartFile.fromString(
|
||||
attachment.name,
|
||||
attachment.binaryAttachmentString,
|
||||
'binary'
|
||||
|
@ -1,11 +1,11 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import { Email } from './email.classes.email.js';
|
||||
import { EmailService } from './email.classes.emailservice.js';
|
||||
|
||||
export class MailgunConnector {
|
||||
public emailRef: Email;
|
||||
public emailRef: EmailService;
|
||||
public mailgunAccount: plugins.mailgun.MailgunAccount;
|
||||
|
||||
constructor(emailRefArg: Email) {
|
||||
constructor(emailRefArg: EmailService) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.mailgunAccount = new plugins.mailgun.MailgunAccount({
|
||||
apiToken: this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_API_TOKEN'),
|
||||
|
@ -1,16 +1,21 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import * as paths from './email.paths.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../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 { logger } from '../logger.js';
|
||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
||||
|
||||
export class Email {
|
||||
|
||||
export interface IEmailConstructorOptions {
|
||||
mailgunApiKey: string;
|
||||
}
|
||||
|
||||
export class EmailService {
|
||||
public platformServiceRef: SzPlatformService;
|
||||
|
||||
// typedrouter
|
||||
public mainTypedRouter = new plugins.typedrequest.TypedRouter();
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// connectors
|
||||
public mailgunConnector: MailgunConnector;
|
||||
@ -22,11 +27,12 @@ export class Email {
|
||||
|
||||
constructor(platformServiceRefArg: SzPlatformService) {
|
||||
this.platformServiceRef = platformServiceRefArg;
|
||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
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) => {
|
||||
new plugins.typedserver.servertools.Handler('POST', async (req, res) => {
|
||||
console.log('Got a mailgun email notification');
|
||||
res.status(200);
|
||||
res.end();
|
@ -1,14 +1,14 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import { Email } from './email.classes.email.js';
|
||||
import { EmailService } from './email.classes.emailservice.js';
|
||||
import { logger } from './email.logging.js';
|
||||
|
||||
export class RuleManager {
|
||||
public emailRef: Email;
|
||||
public emailRef: EmailService;
|
||||
public smartruleInstance = new plugins.smartrule.SmartRule<
|
||||
plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>
|
||||
>();
|
||||
|
||||
constructor(emailRefArg: Email) {
|
||||
constructor(emailRefArg: EmailService) {
|
||||
this.emailRef = emailRefArg;
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
import { SzPlatformService } from './classes.platformservice.js'
|
||||
import { SzPlatformService } from './classes.platformservice.js';
|
||||
|
||||
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 '../classes.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() {}
|
||||
}
|
0
ts/letter/index.ts
Normal file
0
ts/letter/index.ts
Normal file
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',
|
||||
}
|
||||
});
|
8
ts/mta/index.ts
Normal file
8
ts/mta/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export * from './mta.classes.dkimcreator.js';
|
||||
export * from './mta.classes.emailsignjob.js';
|
||||
export * from './mta.classes.dkimverifier.js';
|
||||
export * from './mta.classes.mta.js';
|
||||
export * from './mta.classes.smtpserver.js';
|
||||
export * from './mta.classes.emailsendjob.js';
|
||||
export * from './mta.classes.mta.js';
|
||||
export * from './mta.classes.email.js';
|
846
ts/mta/mta.classes.apimanager.ts
Normal file
846
ts/mta/mta.classes.apimanager.ts
Normal file
@ -0,0 +1,846 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Email, IEmailOptions } from './mta.classes.email.js';
|
||||
import { DeliveryStatus } from './mta.classes.emailsendjob.js';
|
||||
import type { MtaService } from './mta.classes.mta.js';
|
||||
import type { IDnsRecord } from './mta.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Manager for MTA service
|
||||
*/
|
||||
export class ApiManager {
|
||||
/** TypedRouter for API routing */
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
/** MTA service reference */
|
||||
private mtaRef: MtaService;
|
||||
/** Express app */
|
||||
private app: 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;
|
||||
|
||||
// Initialize Express app
|
||||
this.app = plugins.express();
|
||||
|
||||
// Default authentication options
|
||||
this.authOptions = {
|
||||
apiKeys: new Map(),
|
||||
validateIp: false,
|
||||
allowedIps: []
|
||||
};
|
||||
|
||||
// Configure middleware
|
||||
this.configureMiddleware();
|
||||
|
||||
// Register routes
|
||||
this.registerRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Express middleware
|
||||
*/
|
||||
private configureMiddleware(): void {
|
||||
// JSON body parser
|
||||
this.app.use(plugins.express.json({ limit: '10mb' }));
|
||||
|
||||
// CORS middleware
|
||||
this.app.use((req: any, res: any, next: any) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Request logging
|
||||
this.app.use((req: any, res: any, next: any) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
console.log(`[API] ${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Authentication middleware
|
||||
this.app.use((req: any, res: any, next: any) => {
|
||||
// Store authentication level in 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];
|
||||
const decoded = plugins.jwt.verify(token, this.authOptions.jwtSecret);
|
||||
|
||||
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.ip || req.connection.remoteAddress;
|
||||
if (!this.authOptions.allowedIps.includes(clientIp)) {
|
||||
return res.status(403).json({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'IP address not allowed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
});
|
||||
|
||||
// Map routes to Express
|
||||
this.mapRoutesToExpress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an API route
|
||||
* @param route Route definition
|
||||
*/
|
||||
private addRoute(route: ApiRoute): void {
|
||||
this.routes.push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map defined routes to Express
|
||||
*/
|
||||
private mapRoutesToExpress(): void {
|
||||
for (const route of this.routes) {
|
||||
const { method, path, handler, authLevel } = route;
|
||||
|
||||
// Add Express route
|
||||
this.app[method.toLowerCase()](path, async (req: any, res: any) => {
|
||||
try {
|
||||
// Check authentication
|
||||
if (authLevel !== 'none' && req.authLevel !== authLevel && req.authLevel !== 'admin') {
|
||||
return res.status(403).json({
|
||||
code: 'FORBIDDEN',
|
||||
message: `This endpoint requires ${authLevel} access`
|
||||
});
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
if (route.rateLimit) {
|
||||
const exceeded = this.checkRateLimit(route, req);
|
||||
if (exceeded) {
|
||||
return res.status(429).json({
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: 'Rate limit exceeded, please try again later'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the request
|
||||
await handler(req, res);
|
||||
} catch (error) {
|
||||
console.error(`Error handling ${method} ${path}:`, 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;
|
||||
}
|
||||
|
||||
res.status(status).json(apiError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add 404 handler
|
||||
this.app.use((req: any, res: any) => {
|
||||
res.status(404).json({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Endpoint not found'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.ip || req.connection.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.createAndStoreDKIMKeys(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.app.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 {
|
||||
// Nothing to do if not running
|
||||
console.log('API server stopped');
|
||||
}
|
||||
}
|
119
ts/mta/mta.classes.dkimcreator.ts
Normal file
119
ts/mta/mta.classes.dkimcreator.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
|
||||
import { Email } from './mta.classes.email.js';
|
||||
import type { MtaService } from './mta.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(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
|
||||
private 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
|
||||
private 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
|
||||
private 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.`);
|
||||
}
|
||||
|
||||
private 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/mta.classes.dkimverifier.ts
Normal file
35
ts/mta/mta.classes.dkimverifier.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { MtaService } from './mta.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 };
|
558
ts/mta/mta.classes.dnsmanager.ts
Normal file
558
ts/mta/mta.classes.dnsmanager.ts
Normal file
@ -0,0 +1,558 @@
|
||||
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(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(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 {
|
||||
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/mta.classes.email.ts
Normal file
219
ts/mta/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/mta.classes.emailsendjob.ts
Normal file
623
ts/mta/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 './mta.classes.email.js';
|
||||
import { EmailSignJob } from './mta.classes.emailsignjob.js';
|
||||
import type { MtaService } from './mta.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/mta.classes.emailsignjob.ts
Normal file
69
ts/mta/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/mta.classes.mta.ts
Normal file
945
ts/mta/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 './mta.classes.email.js';
|
||||
import { EmailSendJob, DeliveryStatus } from './mta.classes.emailsendjob.js';
|
||||
import { DKIMCreator } from './mta.classes.dkimcreator.js';
|
||||
import { DKIMVerifier } from './mta.classes.dkimverifier.js';
|
||||
import { SMTPServer, type ISmtpServerOptions } from './mta.classes.smtpserver.js';
|
||||
import { DNSManager } from './mta.classes.dnsmanager.js';
|
||||
import { ApiManager } from './mta.classes.apimanager.js';
|
||||
import type { SzPlatformService } from '../classes.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/mta.classes.smtpserver.ts
Normal file
476
ts/mta/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 './mta.classes.email.js';
|
||||
import type { MtaService } from './mta.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');
|
||||
});
|
||||
}
|
||||
}
|
12
ts/paths.ts
Normal file
12
ts/paths.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export const packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
);
|
||||
export const assetsDir = plugins.path.join(packageDir, './assets');
|
||||
export const keysDir = plugins.path.join(assetsDir, './keys');
|
||||
export const dnsRecordsDir = plugins.path.join(assetsDir, './dns-records');
|
||||
export const sentEmailsDir = plugins.path.join(assetsDir, './sent-emails');
|
||||
export const receivedEmailsDir = plugins.path.join(assetsDir, './received-emails');
|
||||
plugins.smartfile.fs.ensureDirSync(keysDir);
|
@ -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 };
|
77
ts/plugins.ts
Normal file
77
ts/plugins.ts
Normal file
@ -0,0 +1,77 @@
|
||||
// node native
|
||||
import * as dns from 'dns';
|
||||
import * as fs from 'fs';
|
||||
import * as crypto from 'crypto';
|
||||
import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import * as tls from 'tls';
|
||||
import * as util from 'util';
|
||||
|
||||
export {
|
||||
dns,
|
||||
fs,
|
||||
crypto,
|
||||
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 smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
|
||||
export { projectinfo, qenv, smartdata, smartfile, smartlog, smartmail, smartpath, smartpromise, smartrequest, 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,
|
||||
}
|
1
ts/sms/index.ts
Normal file
1
ts/sms/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './smsservice.js';
|
89
ts/sms/smsservice.ts
Normal file
89
ts/sms/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 '../classes.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() {}
|
||||
}
|
Reference in New Issue
Block a user